iZettle / Presentation

Presentation is an iOS Swift library for working with UI presentations in a more structured way
MIT License
72 stars 11 forks source link
flow ios presentation reactive swift ui

Build Status Platforms Carthage Compatible Xcode version

Presentation is an iOS Swift library for working with UI presentations in a more structured way with a focus on:

Even though Presentation is flexible it is also opinionated and has a preferred way of building presentations:

The Presentation framework builds heavily upon the Flow framework to handle event handling, asynchronous flows, and lifetime management.

Example usage

// Declare the model and data needed to present the messages UI
struct Messages {
  var messages: ReadSignal<Message>
}

// Conform to Presentable and implement materialize to produce  
// a UIViewController and a Disposable for life-time management
extension Messages: Presentable {
  func materialize() -> (UIViewController, Disposable) { 
    // Setup viewController and views
    let viewController = UITableViewController()

    // Set up event handlers
    let bag = DisposeBag()
    bag += messages.atOnce().onValue { // update table view }

    return (viewController, bag)
  }
}   

/// Create an instance and present it
let messages = Messages(...)
presentingViewController.present(messages)

Contents:

Requirements

Installation

Carthage

github "iZettle/Presentation" >= 1.0

Cocoa Pods

platform :ios, '9.0'
use_frameworks!

target 'Your App Target' do
  pod 'PresentationFramework', '~> 1.0'
end

Usage

Presentation is based on a few core types and methods:

Presentable

Presentation encourages you to declare a presentation-model up-front where all data is provided to set up, update and complete a presentation.

struct Messages {
  var messages: ReadSignal<Message>
  var filter: (String?) -> ()
  var refresh: () -> Future<()>
}

You conform this model to the Presentable protocol to describe how to materialize the model into something to present and the result of this presentation:

extension Messages: Presentable {
  func materialize() -> (UIViewController, Disposable) { 
    // Setup view controller and views
    let viewController = UITableViewController()

    // Set up event handlers
    let bag = DisposeBag()
    bag += messages.atOnce().onValue { // update table view }

    return (viewController, bag)
  }
}   

The Presentable protocol is defined as:

public protocol Presentable {
  associatedtype Matter
  associatedtype Result

  func materialize() -> (Matter, Result)
}

Here Matter is typically a UIViewController or a sub-class of thereof, and the Result of the presentation is usually one of the three core types of Flow.

Returning a Disposable is common when someone external to the presentable is dismissing the presentation such as when presented in a tab bar or from a menu. This is true for our Messages model above.

But if the presentable will drive the completion such as when collecting some data in a form you typically return a Future instead:

struct ComposeMessage { }

extension ComposeMessage: Presentable {
  func materialize() -> (UIViewController, Future<Message>) {
    // Setup view controller and views
    let viewController = UIViewController(...)
    viewController.view = ...

    return (viewController, Future { completion in
      // Setup event handlers
      let bag = DisposeBag()

      bag += postButton.onValue { 
        completion(.success(Message(...))
      }

      return bag
    })
  }
}

Even though our compose message model has no data we still define a type for it to be able to refer to it and conform it to Presentable. Here the returned future will complete with a composed message on success.

Finally, returning a Signal indicates that the presentation might signal a result several times, such as when being presented on a navigation stack, where signaling will push the next view controller, but we can still come back to the previous one.

For example, Messages could potentially be updated to return a Signal<Message> instead, to indicate that details about the message should be shown:

extension Messages: Presentable {
  func materialize() -> (UIViewController, Signal<Message>) { ... }
}   

Presenting

Once you have conformed your presentation model to Presentable you can conveniently present it from another view controller:

let messages = Messages(...)
presentingController.present(messages)

Calling present() will return a future that can be used to abort a presentation:

let future = presentingController.present(messages)

future.cancel() // Abort and dismiss the presentation

It is also possible to present a presentable on a window to set it as the root controller:

bag += window.present(messages)

If the presentable returns a future or a signal, present() will return one as well allowing you to retreive the result:

let compose = ComposeMessage(...)
presentingController.present(compose).onValue { message in
  // Called with a composed message on dismiss
}

Customization

The present() methods takes three defaulted arguments, style, options and configure, that you can provide to customize a presentation:

controller.present(messages, style: .modal, options: [ ... ]) { vc, bag in
  // Customize view controller and use bag to add presentation activities 
}

Style

Presentation comes with several PresentationStyles such as default, modal, popover and embed. But you can also extend it with your custom ones:

extension PresentationStyle {
  static let customStyle = PresentationStyle(name: "custom") { 
    viewController, presentingController, options in
      // Run and animate presentation and define how to dismiss  
      return (result, dismiss)
    }
}

Options

You can also provide options to customize the presentation. Presentation comes with several PresentationOptions such as embedInNavigationController, restoreFirstResponder and showInMaster. You can also add more options for your custom presentation styles:

extension PresentationOptions {
  static let customOption = PresentationOptions()
}  

Configure

Finally, you can optionally provide a configuration closure that will be called with the view controller that is just about to be presented together with a DisposeBag that will let you add activities that will be kept alive for the duration of the presentation:

controller.present(messages) { vc, bag in
  // Customize view controller and use bag to add presentation activities 
}

It is useful to add a dismiss button to dismiss a presentation and Presentation adds a convenience function for adding these to a presented view controller:

let done = UIBarButtonItem(...)
controller.present(messages, configure: installDismiss(done))

Presentation

The Presentation type bundles a presentable together with presentation parameters and actions to call once being presented or dismissed. A Presentation can be presented similarly as a Presentable but without any explicit presentation parameters:

let compose = Presentation(ComposeMessage(), style: ..., options: ..., configure: ...)

presentingController.present(compose)

By using Presentations in our presentable's model data we will relieve it from the knowledge how to construct and present other presentables:

struct Messages {
  let messages: ReadSignal<[Message]>
  let composeMessage: Presentation<ComposeMessage>
}

Using presentations is a great way to decouple two UIs and removes the need to forward any model data needed to construct and present other presentations.

So far we have shown how to define and materialize our presentables, but not how to initialize them. It is important to realize that the data needed to initialize a presentable is not necessary data that the presentable itself need to know about. To make this decoupling of initialization and presentation more explicit, it could be useful to separate these into separate files, and potentially separate modules as well. The initializer could, for example, be given access to resources not available from the presentable's own module.

extension Messages {
  init(messages: [Message]) {
    let messagesSignal = ReadWriteSignal(messages)
    self.messages = messagesSignal.readOnly()

    let compose = Presentation(ComposeMessage(), style: .modal)
    self.composeMessage = compose.onValue { message in
      messagesSignal.value.insert(message, at: 0)
    }
  }
}

Here we can see that a Presentation also allows adding actions and transformations to be called upon its presentation and dismissal. In this case, we will set up an action to be called once the compose presentation is being successfully dismissed with the newly composed message. The new message will just be prepended to our messagesSignal that will signal our Messages presentable to update its table view.

It is important to realize that the above initialization of Messages is just one of many potential implementations. In a more realistic example, we might include network requests and some kind of persistence store such as CoreData. But however we chose to implement this, it will not affect our presentable type and its materialize implementation. We might even have several initializers for different purposes such as production, unit-testing and sample apps.

A useful way to view a presentable is as a recipe on how to present something. Having an instance of a presentable does not mean that any UI has yet been constructed. The user might never choose to compose a message, or might compose more than one. But we still just have one instance of the compose presentable.

Type-erased Presentable

Sometimes it can be useful to anonymize a presentable, for example, to be able to pass different presentables interchangeable to an API.

let anonymized = AnyPresentable(message) // AnyPresentable<UIViewController, Disposable>

As AnyPresentable is just a type-erased Presentable it can also be used with Presentation. You can, for example, use it to wrap some legacy view controller you do not feel the need to add a model for:

let presentation = DisposablePresentation {
  let vc = storyboard.instantiateViewController(...)
  let bag = DisposeBag()
  // setup vc and potential activities

  return (vc, bag)
}

Where DisposablePresentation is just a type alias for:

typealias DisposablePresentation = AnyPresentation<UIViewController, Disposable>

And AnyPresentation as is a type alias for:

typealias AnyPresentation<Context: UIViewController, Result> = Presentation<AnyPresentable<Context, Result>>

Alerts

Presentation comes with a built in Alert presentable to make it more convenient to work with alerts and action sheets:

let alert = Alert<Bool>(title: ..., message: ..., actions: 
  Action(title: "Yes") { true }
  Action(title: "No") { false }
  Action(title: "Cancel", style: .cancel) { throw CancelError() },
)

// Display as an alert
presentingController.present(alert).onValue { boolean in
  // Selected yes or no 
}.onError { error in
  // Did cancel
}

// Display as an action sheet
presentingController.present(alert, style: .sheet())

Alerts also supports editing of fields:

var add = Alert<URL>.Action(title: "Add") { URL(string: $0[0])! }
add.enabledPredicate = { isValidURL($0[0]) }
let alert = Alert(title: "Add server", fields: [.Field()], actions: add)

presentingController.present(alert).onValue { url in ... }

Master-detail

Presentation also comes with some helper types to make it easier to work with master-detail UIs such as split view controllers:

Form framework

We highly recommend that you also check out the Form framework. Form and Presentation were developed closely together and share many of the same underlying design philosophies.

Field tested

Presentation was developed, evolved and field-tested over the course of several years, and is pervasively used in iZettle's highly acclaimed point of sales app.

Collaborate

You can collaborate with us on our Slack workspace. Ask questions, share ideas or maybe just participate in ongoing discussions. To get an invitation, write to us at iz-apps-platform-ios@paypal.com