Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Creating Apps Without Interface Builder

Creating Apps Without Interface Builder

A slide deck for a talk I gave in November 2014 discussing creating Swift applications for iOS without using interface builder.

For more information, visit my blog: http://flexmonkey.blogspot.co.uk

simon gladman

March 23, 2015
Tweet

More Decks by simon gladman

Other Decks in Programming

Transcript

  1. Biography • Almost twenty years of application development • Mainly

    advertising production and investment banking • Campaign management, media booking, time and expenses, FX trading, business activity monitoring and approval systems • ColdFusion, MS SQL, JavaScript, ActionScript 3, Apache Flex
  2. • Regular blogger: flexmonkey.blogspot.com • Twitter: @flexmonkey • Github: https://github.com/flexmonkey

    • Creator of Nodality • Node based image editing and compositing for iPad
  3. The demo app A colour selection app for iPad that

    allows users to: • Create colours from red, green and blue sliders. • Select colours from a collection of presets. • Save and name their own colours.
  4. • The application contains four main components • A ColorPicker

    - contains a UIPickerView for selecting from a list of preset colours • An RGBWidget - contains three UISliders for setting the red, green and blue components • A SavedColorsGrid - contains a UICollectionView to display the user’s selected colours • A ColorSwatch to display the current colour
  5. • The main value object used is a NamedColor •

    It has properties for its color and name • It has a constant, immutable uuid let uuid = NSUUID().UUIDString • It is equatable via a custom operator func == (lhs: NamedColor, rhs: NamedColor) -> Bool { return lhs.uuid == rhs.uuid }
  6. Landscape layout • I wanted the application to lay itself

    out differently when the iPad is rotated. • In landscape, I’ve broken the screen up into a grid based on the rule of thirds.
  7. Portrait layout • In portrait, the four components are stacked

    vertically, each taking up a quarter of the available space.
  8. Laying out in code • My own preference not to

    use Xcode’s Interface Builder and create and layout components in Swift • The advantage is I know what my code is doing and I don’t need to tweak generated code. • The disadvantage is that I need to keep running in the simulator to see how my interface looks.
  9. Creating components • My components are declared as constants in

    the view controller let colorSwatch =ColorSwatch(frame: CGRectZero) let toolbar = UIToolbar(frame: CGRectZero) let colorPicker = ColorPicker(frame: CGRectZero) let rgbWidget = RGBWidget(frame: CGRectZero) let savedColorsGrid = SavedColorsGrid(frame: CGRectZero) • They’re added as sub-views in viewDidLoad() override func viewDidLoad() { super.viewDidLoad() view.addSubview(colorSwatch) view.addSubview(colorPicker) view.addSubview(rgbWidget) view.addSubview(savedColorsGrid) view.addSubview(toolbar) […]
  10. Laying out components • Now the components are available, I

    define their frames inside an overridden viewDidLayoutSubviews() override func viewDidLayoutSubviews() { if view.frame.isLandscape() { // do landscape layout } else { // do portrait layout } } }
  11. isLandscape() ? • A view is a CGRect which doesn’t

    have an isLandscape() method • I used a Swift extension to add that functionality extension CGRect { func isLandscape() -> Bool { return self.width > self.height } } • Segregates responsibility and helps make code self documenting
  12. A look at the landscape layout code let toolbarHeight: CGFloat

    = 50 let contentHeight = view.frame.height - toolbarHeight let margin = CGFloat(10) if view.frame.isLandscape() { let leftColumnWidth = view.frame.width * 0.34 let rightColumnWidth = view.frame.width * 0.66 let upperRowHeight = contentHeight * 0.66 let bottomRowHeight = contentHeight * 0.34 - topLayoutGuide.length let upperRowTop = topLayoutGuide.length let bottomRowTop = upperRowHeight + upperRowTop savedColorsGrid.frame = CGRect(x: 0, y: upperRowTop, width: leftColumnWidth, height: upperRowHeight).rectByInsetting(dx: margin, dy: margin) colorSwatch.frame = CGRect(x: leftColumnWidth, y: upperRowTop, width: rightColumnWidth, height: upperRowHeight).rectByInsetting(dx: margin, dy: margin) colorPicker.frame = CGRect(x: 0, y: bottomRowTop, width: leftColumnWidth, height: bottomRowHeight).rectByInsetting(dx: margin, dy: margin) rgbWidget.frame = CGRect(x: leftColumnWidth, y: bottomRowTop, width: rightColumnWidth, height: bottomRowHeight).rectByInsetting(dx: margin, dy: margin) }
  13. The Panel base component Rather than duplicating the common visual

    code in each of the four components, I created a Panel class which each extends. class Panel: UIControl { override init(frame: CGRect) { super.init(frame: frame) layer.backgroundColor = UIColor.lightGrayColor().CGColor layer.shadowColor = UIColor.blackColor().CGColor layer.shadowOffset = CGSize(width: 0, height: 0) layer.shadowOpacity = 0.5 layer.cornerRadius = 5 layer.borderColor = UIColor.darkGrayColor().CGColor layer.borderWidth = 1 layer.cornerRadius = 5 } }
  14. The simplest of the components - simply sets its own

    layer’s background colour via a didSet observer on its currentColor property: class ColorSwatch: Panel { var currentColor:UIColor = UIColor.blackColor() { didSet { layer.backgroundColor = currentColor.CGColor } } }
  15. • Contains three SliderWidgets for changing red, green and blue

    values of a colour. • These are created and held in an array in init(): redWidget = SliderWidget(frame: CGRectZero) greenWidget = SliderWidget(frame: CGRectZero) blueWidget = SliderWidget(frame: CGRectZero) widgets = [redWidget, greenWidget, blueWidget] • Then added as sub-views by looping over the array: override func didMoveToSuperview() { for (i: Int, widget: SliderWidget) in enumerate(widgets) { addSubview(widget) widget.title = widgetTitles[i] widget.addTarget(self, action: "sliderChangeHandler", forControlEvents: UIControlEvents.ValueChanged) } } • When any slider is changed, the sliderChangeHandler() is invoked.
  16. The SliderWidget is also a custom component that contains a

    UILabel and a UISlider let slider = UISlider(frame: CGRectZero) let label = UILabel(frame: CGRectZero) […] override func didMoveToSuperview() { slider.addTarget(self, action: "sliderChangeHandler", forControlEvents: .ValueChanged) layer.cornerRadius = 5 layer.backgroundColor = UIColor.darkGrayColor().colorWithAlphaComponent(0.25).CGColor addSubview(slider) addSubview(label) } override func layoutSubviews() { label.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height / 2).rectByInsetting(dx: 5, dy: 5) slider.frame = CGRect(x: 0, y: frame.height / 2, width: frame.width, height: frame.height / 2).rectByInsetting(dx: 5, dy: 5) }
  17. When either its value or title are changed, it updates

    its slider and label: var title: String = "" { didSet { updateLabel() } } var value: Float = 0 { didSet { slider.value = value updateLabel() } } func updateLabel() { label.text = title + ": " + value.format() }
  18. The slider widgets are also laid out using code: override

    func layoutSubviews() { let widgetHeight = frame.height / 3 let margin = CGFloat(6) for (i: Int, widget: SliderWidget) in enumerate(widgets) { widget.frame = CGRect(x: 0, y: CGFloat(i) * widgetHeight, width: frame.width, height: widgetHeight).rectByInsetting(dx: margin, dy: margin) } }
  19. • The handler changes the component’s currentColor property: func sliderChangeHandler()

    { enableObserversOnColorComponents = false currentColor = UIColor.colorFromFloats(redComponent: redWidget.value, greenComponent: greenWidget.value, blueComponent: blueWidget.value) enableObserversOnColorComponents = true } • currentColor uses a didSet observer to set the individual color components and dispatch its own control event: var currentColor: UIColor = UIColor.blackColor() { didSet { redComponent = currentColor.getRGB().redComponent greenComponent = currentColor.getRGB().greenComponent blueComponent = currentColor.getRGB().blueComponent sendActionsForControlEvents(UIControlEvents.ValueChanged) } } • Those individual components set the respective sliders - if the current colour hasn’t been set by a slider: private var greenComponent: Float = 0 { didSet { if enableObserversOnColorComponents { greenWidget.value = greenComponent } } }
  20. getRGB() and colorFromFloats() Two methods on UIColor you won’t have

    seen before - both added as extensions class func colorFromFloats(#redComponent: Float, greenComponent: Float, blueComponent: Float) -> UIColor { return UIColor(red: CGFloat(redComponent), green: CGFloat(greenComponent), blue: CGFloat(blueComponent), alpha: 1.0) } func getRGB() -> (redComponent: Float, greenComponent: Float, blueComponent: Float) { let colorRef = CGColorGetComponents(self.CGColor); let redComponent = Float(colorRef[0]) let greenComponent = Float(colorRef[1]) let blueComponent = Float(colorRef[2]) return (redComponent: redComponent, greenComponent: greenComponent, blueComponent: blueComponent) }
  21. • The ColorPicker contains a UIPickerView and acts as both

    UIPickerViewDataSource and UIPickerViewDelegate • It contains an immutable array of preset colours • It has a currentColor property which refers to the selected colour which is set: • From the picker itself • When the user changes the sliders • When the user selects a saved color
  22. • UIPickerViewDataSource is used to define the number of rows

    in the picker func pickerView(pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { return colors.count } • UIPickerViewDelegate is used to define the item renderer: func pickerView(pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusingView view: UIView!) -> UIView { let rendererColor = (row == 0) ? NamedColor(name: "Custom", color: currentColor) : colors[row] let namedColorRenderer = NamedColorItemRenderer(frame: CGRect(x: 0, y: 0, width: frame.width, height: rowHeight)) namedColorRenderer.editable = false namedColorRenderer.namedColor = rendererColor return namedColorRenderer } • …and to report a change in selection: func pickerView(pickerView: UIPickerView!, didSelectRow row: Int, inComponent component: Int) { currentColor = colors[row].color }
  23. When the ColorPicker’s current colour is changed, either internally or

    externally, it dispatches a control event and attempts to find a match in its array of presets. If there’s no match, it sets the picker to the ‘custom colour’ item. var currentColor : UIColor = UIColor.blackColor() { didSet { sendActionsForControlEvents(.ValueChanged) var matchFound = false if let _colorIndex = findColorInNamedColors(colors, currentColor) { spinner.selectRow(_colorIndex, inComponent: 0, animated: true) matchFound = true } if !matchFound { spinner.selectRow(0, inComponent: 0, animated: true) spinner.reloadComponent(0) } } }
  24. • The SavedColorsGrid contains a UICollectionView and acts as its

    UICollectionViewDataSource and UICollectionViewDelegate • It has a colors array which is a collection of the colours saved by the user • It also supports a ‘tap-hold’ gesture to pop up a little menu to delete saved colours
  25. Both the picker and the collection view use the same

    item renderer, NamedColorItemRenderer, but the collection view’s code is slightly different. • I need to register the renderer class: uiCollectionView.registerClass(NamedColorItemRenderer.self, forCellWithReuseIdentifier: "Cell") • Then use one of the delegate functions to define the cell for a given index: func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCellWithReuseIdentifier("Cell", forIndexPath: indexPath) as NamedColorItemRenderer cell.hasBackground = true cell.editable = true cell.namedColor = colors[indexPath.item] return cell }
  26. When the SavedColorsGrid’s colors property is changed, I want any

    changes, e.g. additions or deletions, to animate nicely. var colors: [NamedColor] = [NamedColor]() { didSet { if oldValue.count < colors.count && colors.count - oldValue.count == 1 && oldValue.count != 0 { for newColor in colors { if find(oldValue, newColor) == nil { let newIndex = find(colors, newColor) let insertIndexPath = NSIndexPath(forItem: newIndex!, inSection: 0) uiCollectionView.insertItemsAtIndexPaths([insertIndexPath]) } } } else if oldValue.count > colors.count { for color in oldValue { if find(colors, color) == nil { let deletedIndex = find(oldValue, color) let deleteIndexPath = NSIndexPath(forItem: deletedIndex!, inSection: 0) uiCollectionView.deleteItemsAtIndexPaths([deleteIndexPath]) } } } else { uiCollectionView.reloadData() } } }
  27. By implementing the action related functions in UICollectionViewDelegate, I can

    pop up the ‘cut’ option to delete colors func collectionView(collectionView: UICollectionView, shouldShowMenuForItemAtIndexPath indexPath: NSIndexPath) -> Bool { return true } func collectionView(collectionView: UICollectionView, canPerformAction action: Selector, forItemAtIndexPath indexPath: NSIndexPath, withSender sender: AnyObject!) -> Bool { return action.description == "cut:" } func collectionView(collectionView: UICollectionView, performAction action: Selector, forItemAtIndexPath indexPath: NSIndexPath, withSender sender: AnyObject!) { if action.description == "cut:" { colors = colors.filter({$0 != self.colors[indexPath.item]}) sendActionsForControlEvents(UIControlEvents.SavedColorDeleted) } }
  28. The NamedColorItemRenderer • The item renderer used in both the

    picker and collection view • Contains an editable text input, a readonly label and a colour swatch • When its namedColor property changes, the sub-components are updated using a didSet observer: var namedColor: NamedColor? { didSet { if let _namedColor = namedColor { label.text = _namedColor.name textInput.text = _namedColor.name swatch.backgroundColor = _namedColor.color } } }
  29. • Switches between a read only label or an editable

    text input by a didSet observer on its editable property: var editable: Bool = false { didSet { if editable { label.removeFromSuperview() addSubview(textInput) } else { textInput.removeFromSuperview() addSubview(label) } } }
  30. • Because it is the text field’s UITextFieldDelegate, it can

    respond to edits. • Because NamedColor is a class, the renderer holds a reference to it and changes to it are available up in the view controller func textFieldShouldReturn(textField: UITextField) -> Bool { textField.endEditing(true) return true } func textFieldDidEndEditing(textField: UITextField) { if namedColor != nil { namedColor!.name = textInput.text } }
  31. • The toolbar is an instance of UIToolbar that’s added

    as a sub-view and laid out in the view controller. • The toolbar contains three items • Save • Tweak • About • Which are added in populateToolbar() func populateToolbar() { let saveBarButtonItem = UIBarButtonItem(title: "Save", style: UIBarButtonItemStyle.Plain, target: self, action: "saveCurrentColor") let tweakBarButtonItem = UIBarButtonItem(title: "Tweak", style: UIBarButtonItemStyle.Plain, target: self, action: "showTweakMenu:") let aboutBarButtonItem = UIBarButtonItem(title: "About", style: UIBarButtonItemStyle.Plain, target: self, action: "showAbout") let spacer = UIBarButtonItem(barButtonSystemItem: UIBarButtonSystemItem.FlexibleSpace, target: nil, action: nil) toolbar.setItems([saveBarButtonItem, spacer, tweakBarButtonItem, spacer, aboutBarButtonItem], animated: true) }
  32. “Tweak” • The “tweak” toolbar button pops up a small

    alert view. • In the view controller’s init() function, I create an alert controller of style ActionSheet and give it some actions: alertController = UIAlertController(title: nil, message: nil, preferredStyle: UIAlertControllerStyle.ActionSheet) makeDarkerAlertAction = UIAlertAction(title: "Make darker", style: UIAlertActionStyle.Default, handler: adjustColor) makeDarkerLighterAction = UIAlertAction(title: "Make lighter", style: UIAlertActionStyle.Default, handler: adjustColor) alertController.addAction(makeDarkerLighterAction) alertController.addAction(makeDarkerAlertAction) • To display the menu, I invoke presentViewController(): func showTweakMenu(value: UIBarButtonItem) { alertController.popoverPresentationController?.barButtonItem = value presentViewController(alertController, animated: true, completion: nil) }
  33. “About” • “About” also uses a UIAlertController, but this time

    of style Alert: func showAbout() { var alertController = UIAlertController(title: "London Swift Demo Application", message: "Simon Gladman | November 2014", preferredStyle: UIAlertControllerStyle.Alert) let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) let openBlogAction = UIAlertAction(title: "Open Blog", style: .Default, handler: visitFlexMonkey) alertController.addAction(okAction) alertController.addAction(openBlogAction) presentViewController(alertController, animated: true, completion: nil) }
  34. • All the interactive components dispatch UIControlEvents and the view

    controller listens for these: rgbWidget.addTarget(self, action: "rgbWidgetChangeHandler", forControlEvents: UIControlEvents.ValueChanged) colorPicker.addTarget(self, action: "colorPickerChangeHandler", forControlEvents: UIControlEvents.ValueChanged) savedColorsGrid.addTarget(self, action: "savedColorsGridSelectHandler", forControlEvents: UIControlEvents.SavedColorSelected) • These three events do pretty much the same thing: change the view controller’s own currentColor: func colorPickerChangeHandler() { currentColor = colorPicker.currentColor } func rgbWidgetChangeHandler() { currentColor = rgbWidget.currentColor } func savedColorsGridSelectHandler() { currentColor = savedColorsGrid.getSelectedColor() }
  35. • The view controller’s currentColor has a didSet observer that

    sets the currentColor on the control that hasn’t changed: var currentColor: UIColor = UIColor.brownColor() { didSet { if !(rgbWidget.currentColor == currentColor) { rgbWidget.currentColor = currentColor } if !(colorPicker.currentColor == currentColor) { colorPicker.currentColor = currentColor } colorSwatch.currentColor = currentColor } }
  36. • The view controller also has a mutable savedColors array

    which contains all the colours the user has saved • A didSet() observer of this array passes it into the saved colours grid when changed var savedColors: [NamedColor] = [NamedColor]() { didSet { savedColorsGrid.colors = savedColors } }
  37. Persisting Data with Core Data • Core Data allows the

    application to persist data, the saved user defined colours, after restarting. • If you forget to check the ‘use Core Data’ option, create a new project with that checked and copy the guts of AppDelegate into your existing project.
  38. Create an entity • Create a new Core Data Data

    Model class. • Define the properties of the entity. • Create an NSManagedObject version of the entity.
  39. • The generated code in AppDelegate contains all the boilerplate

    code to get started. • I create a context in the view controller: appDelegate = UIApplication.sharedApplication().delegate as AppDelegate managedObjectContext = appDelegate.managedObjectContext! • To load saved items from a previous session, invoke executeFetchRequest on the context: fetchResults = managedObjectContext.executeFetchRequest(fetchRequest, error: nil)
  40. • For this application, I want to cast the results

    as an array of NamedColor and convert each retrieved item as an instance of NamedColor func loadSavedColors() { let fetchRequest = NSFetchRequest(entityName: "NamedColorEntity") if let fetchResults = managedObjectContext.executeFetchRequest(fetchRequest, error: nil) as? [NamedColorEntity] { var loadedColors = [NamedColor]() for namedColorEntity in fetchResults { loadedColors.append(NamedColorEntity.createInstanceFromEntity(namedColorEntity)) } savedColors = loadedColors } } class func createInstanceFromEntity(entity: NamedColorEntity) -> NamedColor! { let name = entity.colorName let color = UIColor.colorFromNSNumbers(redComponent: entity.redComponent, greenComponent: entity.greenComponent, blueComponent: entity.blueComponent) var namedColor = NamedColor(name: name, color: color) namedColor.entityRef = entity return namedColor }
  41. • To save a new user defined color, I do

    the opposite: create a DTO from an instance of NamedColor and invoke insertNewObjectForEntityForName() func saveCurrentColor() { let savedColor = NamedColor(name: currentColor.getHex(), color: currentColor) savedColors.insert(savedColor, atIndex: 0) NamedColorEntity.createInManagedObjectContext(managedObjectContext, namedColor: savedColor) appDelegate.saveContext() } class func createInManagedObjectContext(moc: NSManagedObjectContext, namedColor: NamedColor) -> NamedColorEntity { let newEntity = NSEntityDescription.insertNewObjectForEntityForName("NamedColorEntity", inManagedObjectContext: moc) as NamedColorEntity let colorComponents = namedColor.color.getRGB() newEntity.redComponent = colorComponents.redComponent newEntity.greenComponent = colorComponents.greenComponent newEntity.blueComponent = colorComponents.blueComponent newEntity.colorName = namedColor.name namedColor.entityRef = newEntity return newEntity }
  42. • Because I have a reference to the entity inside

    NamedColor, I can persist edits inside the domain object itself: var name : String { didSet { if let _entityRef = entityRef { _entityRef.colorName = name let appDelegate = UIApplication.sharedApplication().delegate as AppDelegate appDelegate.saveContext() } } } • This could be considered a little naughty or could be considered “Domain Driven Development”
  43. • The saved colours grid dispatches a delete event. •

    To delete in Core Data, I compare the saved colours grid’s colors array to the view controller’s and invoke deleteObject() on the missing one: func savedColorsGridDeleteHandler() { for color in savedColors { if find(savedColorsGrid.colors, color) == nil { if let _entityRef = color.entityRef { managedObjectContext.deleteObject(_entityRef) } } } appDelegate.saveContext() savedColors = savedColorsGrid.colors }