Callouts are small, transient popups that display information or user interface controls. As part of my Swift node based calculator experiment, I wanted to add two new features, a numeric dial and a delete node button, which are ideal candidates to be contained in a callout container.
I've used a different approach for both new features and this post describes how I've implemented them.
Popovers
First off, the numeric dial is launched from a UIBarButtonItem in my Toolbar class
The numeric dial is my own component that I blogged about here. In this implementation, I've updated its layers to extend CAShapeLayer and draw its value line immediately through a drawValueCurve() method in the NumericDialTrack class.
The dial itself knows nothing of my presentation model; in keeping with the strategy I've used for years, my low-level UI components are framework and domain agnostic. My NumericDialViewController, which houses the control, does know about the presentation model and has a few observers on it:
NodesPM.addObserver(self, selector: "nodeChangeHandler", notificationType: NodeNotificationTypes.NodeSelected)
NodesPM.addObserver(self, selector: "nodeChangeHandler", notificationType: NodeNotificationTypes.NodeUpdated)
nodeChangeHandler() does little more than normalise the current node's value and update the control to reflect the relevant node's value:
func nodeChangeHandler()
{
if let selectedNode = NodesPM.selectedNode
{
let value = selectedNode.value
ignoreDialChangeEvents = true
numericDial.currentValue = value / 100
ignoreDialChangeEvents = false
}
}
When the user changes the dial position, the controller acts on a .ValueChanged control event on the dial and notifies the presentation model:
func dialChangeHandler(numericDial: NumericDial)
{
let dialValue = Double(Int(numericDial.currentValue * 100))
if !ignoreDialChangeEvents
{
NodesPM.changeSelectedNodeValue(dialValue)
}
}
Up in the Toolbar class, I create an instance of my NumericDialController and an instance of UIPopoverController with the numeric dial controller set as its contentViewController:
let numericDialViewController: NumericDialViewController
let popoverController: UIPopoverController
[...]
numericDialViewController = NumericDialViewController()
popoverController = UIPopoverController(contentViewController: numericDialViewController)
I want to know when the dial callout has been closed and to do that my toolbar implements the UIPopoverControllerDelegate and sets itself to the popover controller's delegate:
popoverController.delegate = self
The user launches the numeric dial callout by clicking a UIBarButtonItem:
let numericButtonShowDial = UIBarButtonItem(title: "Dial", style: UIBarButtonItemStyle.Plain, target: self, action: "showDial:")
The showDial() method actually displays the callout. It does this by checking we have a rootViewControler and then simply invoking presentPopoverFromBarButton():
popoverController.presentPopoverFromBarButtonItem(value, permittedArrowDirections: UIPopoverArrowDirection.Any, animated: true)
One thing I found was that although the callout is meant to be modal, the little calculator buttons were still clickable. Changing the toolbar's userInteractionEnabled to false didn't seem to help, so I created a little function that manually enables or disables the individual button bar buttons:
func enableItems(enable: Bool)
{
if let barButtonItems = items
{
for barButtonItem:AnyObject in barButtonItems
{
(barButtonItem as UIBarButtonItem).enabled = enable;
}
}
}
This is invoked when the callout is opened and, because toolbar is the popover controller's delegate, invoked again when the callout is closed through the popoverControllerDidDismissPopover() method:
func popoverControllerDidDismissPopover(popoverController: UIPopoverController)
{
enableItems(true)
}
Action Sheets
Next up is an ActionSheet which is launched from my MenuButton class.
I could have used the same technique for the menu button, but UIKit offers a simpler alternative for creating action sheets, UIAlertController.
The UIController contains a set of UIAlertAction instances. In my case, I have one for deleting the selected node and two others to toggle nodes between numeric and operator types.
My first steps are to instantiate the UIAlertController and add the actions:
var alertController = UIAlertController(title: nil, message: nil, preferredStyle: UIAlertControllerStyle.ActionSheet)
[...]
makeOperatorAction = UIAlertAction(title: NodeTypes.Operator.toRaw(), style: UIAlertActionStyle.Default, handler: changeNodeType)
makeNumericAction = UIAlertAction(title: NodeTypes.Number.toRaw(), style: UIAlertActionStyle.Default, handler: changeNodeType)
deleteAlertAction = UIAlertAction(title: "Delete Selected Node", style: UIAlertActionStyle.Default, handler: deleteSelectedNode)
[...]
alertController.addAction(deleteAlertAction)
alertController.addAction(makeNumericAction)
alertController.addAction(makeOperatorAction)
Now that the actions have been created, launching them is simply a matter of invoking presentViewController() on the root view controller in the menu button's overridden touchesBegan() method:
override func touchesBegan(touches: NSSet, withEvent event: UIEvent)
{
if let viewController = UIApplication.sharedApplication().keyWindow.rootViewController
{
if let popoverPresentationController = alertController.popoverPresentationController
{
popoverPresentationController.sourceRect = frame
popoverPresentationController.sourceView = viewController.view
viewController.presentViewController(alertController, animated: true, completion: nil)
}
}
}
A little note on NSTimer and userInfo
When I first plugged the numeric dial into this application, performance was pretty grim. Because of the frequent updates to the screen, the dial was very juddery. One of the approaches I took to help with this was to use an NSTimer inside the recursive nodeUpdate() method inside my presentation model.
Although I didn't end up using this approach, I did have to scratch my head for a few minutes to figure out how to pass data from a timer to its selector. I needed to pass a NodeVO and I did this by creating a NSMutableDictionary containing my node and setting that as the timer's userInfo property:
var dictionary = NSMutableDictionary()
dictionary.setValue(candidateNode, forKeyPath: "node")
var timer = NSTimer(timeInterval: timeInterval, target: self, selector: "timerComplete:", userInfo: dictionary, repeats: false)
Then, timerComplete() uses valeForKey() to extract the node from its argument:
func timerComplete(value: AnyObject)
{
let srcTimer: NSTimer = value as NSTimer
let node: NodeVO = srcTimer.userInfo?.valueForKey("node") as NodeVO
NodesPM.nodeUpdate(node)
}
All the updated source code is available in my GitHub repository. Please note: this code has been built under Xcode 6.0 and may not work under 6.1.
Add a comment