devxoul
8/7/2016 - 7:41 PM

Easily roll your own `UITabBarController` alternatives. Here’s all the logic you need without assuming anything about your UI.

Easily roll your own UITabBarController alternatives. Here’s all the logic you need without assuming anything about your UI.

final class TabComponentViewController: UIViewController, TabComponent {
    let tabItem: TabItem
    
    init(title: String) {
        tabItem = TabItem(title: title)
        
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

final class TabItemViewControllerRouterTests: XCTestCase {
    let tabComponent1 = TabComponentViewController(title: "1")
    
    let tabComponent2 = TabComponentViewController(title: "2")
    
    let parentViewController = UIViewController()

    func testNoViewControllerParentsByDetault() {
        let _ = TabItemViewControllerRouter(tabComponents: [tabComponent1, tabComponent2], parentViewController: parentViewController,
                                               viewHierarchyUpdater: { _ in })
        
        XCTAssertNil(tabComponent1.parentViewController)
        XCTAssertNil(tabComponent2.parentViewController)
    }
    
    func testSelectingTabBarItemAddsViewControllerToParent() {
        let router = TabItemViewControllerRouter(tabComponents: [tabComponent1, tabComponent2], parentViewController: parentViewController,
                                                    viewHierarchyUpdater: { _ in })
        router.selectTabItem(tabComponent1.tabItem)
        
        XCTAssertEqual(tabComponent1.parentViewController, parentViewController)
    }
    
    func testSelectingTwoTabBarItemAddsViewControllerToParentAndRemovesPreviousController() {
        let router = TabItemViewControllerRouter(tabComponents: [tabComponent1, tabComponent2], parentViewController: parentViewController,
                                                    viewHierarchyUpdater: { _ in })
        router.selectTabItem(tabComponent1.tabItem)
        router.selectTabItem(tabComponent2.tabItem)
        
        XCTAssertNil(tabComponent1.parentViewController)
        XCTAssertEqual(tabComponent2.parentViewController, parentViewController)
    }
    
    func testSelectionInvokesHierarchyUpdaterWithCorrectView() {
        let expectation = expectationWithDescription("View hierarchy will be updated")
        
        let router = TabItemViewControllerRouter(tabComponents: [tabComponent1, tabComponent2], parentViewController: parentViewController, viewHierarchyUpdater: { view in
            XCTAssertEqual(view, self.tabComponent1.view)
            expectation.fulfill()
        })
        router.selectTabItem(tabComponent1.tabItem)
        
        waitForExpectationsWithTimeout(2) { error in
            if let _ = error {
                XCTFail()
            }
        }
    }
}
/**
 Maintains the state needed to implement your own `UITabController` replacement, without making any assumptions about
 how your UI looks or works.
 */
public final class TabItemViewControllerRouter {
    /// The tab bar items for the view controllers. You can use these when constructing your UI’s buttons
    public let tabItems: [TabItem]
    
    // MARK: - Immutable private state
    
    /// The view controllers that can be routed between
    private let viewControllers: [UIViewController]
    
    /// Mapping of tab bar items to view controllers
    private let tabItemsToViewControllers: [TabItem: UIViewController]
    
    /// The container view controller serving as the `UITabController` replacement
    private weak var parentViewController: UIViewController?
    
    /// A function for adding a view to the container view controller’s hierarchy
    private let viewHierarchyUpdater: UIView -> Void
    
    // MARK: - Mutable private state
    
    /// The currently selected view controller
    private var selectedViewController: UIViewController?
    
    // MARK: - Initialization
    
    /**
     Create a new tab bar item view controller router.
     
     - parameter viewControllers:      The view controllers that can be routed between
     - parameter parentViewController: The container view controller serving as the `UITabController` replacement
     - parameter viewHierarchyUpdater: A function for adding a view to the container view controller’s hierarchy
     
     - returns: New instance
     */
    public init(tabComponents: [TabComponent], parentViewController: UIViewController, viewHierarchyUpdater: UIView -> Void) {
        self.viewControllers = tabComponents.map { $0.viewController }
        self.parentViewController = parentViewController
        self.viewHierarchyUpdater = viewHierarchyUpdater
        
        self.tabItems = tabComponents.map { $0.tabItem }
        self.tabItemsToViewControllers = Dictionary<TabItem, UIViewController>(zip(tabItems, viewControllers))
    }
    
    /**
     Route based on a new tab bar item having been selected.
     
     - parameter tabItem: Selected tab bar item
     */
    public func selectTabItem(tabItem: TabItem) {
        guard let viewController = tabItemsToViewControllers[tabItem] else { return }
        
        selectViewController(viewController)
    }
    
    /**
     Update which view controller is the selected one.
     
     - parameter viewController: Selected view controller
     */
    private func selectViewController(viewController: UIViewController) {
        let oldValue = selectedViewController
        
        oldValue?.willMoveToParentViewController(nil)
        parentViewController?.addChildViewController(viewController)
        
        viewHierarchyUpdater(viewController.view)
        
        oldValue?.removeFromParentViewController()
        viewController.didMoveToParentViewController(parentViewController)
        
        selectedViewController = viewController
    }
}
final class TabItemSelectionStateTests: XCTestCase {
    var tabItem1: TabItem!
    var tabItem2: TabItem!
    var button1: UIButton!
    var button2: UIButton!
    var selectionState: TabItemSelectionState!
    
    override func setUp() {
        tabItem1 = TabItem(title: "Foo")
        tabItem2 = TabItem(title: "Bar")
        button1 = UIButton()
        button2 = UIButton()
        
        selectionState = TabItemSelectionState(
            buttons: [button1, button2],
            tabItems: [tabItem1, tabItem2]
        )
    }

    func testInitialSelection() {
        XCTAssertEqual(selectionState.selectedTabItem.value, tabItem1)
    }
    
    func testSelectionUpdatesSelectedValue() {
        selectionState.selectButton(button2)
        XCTAssertEqual(selectionState.selectedTabItem.value, tabItem2)
    }
    
    func testSelectingSameValueDoesNotTriggerObservable() {
        selectionState.selectButton(button1)
        selectionState.selectedTabItem.bind { item in
            XCTFail()
        }
    }
}
/**
 Manages selection states for buttons intended to behave in a tab bar-like fashion. Intended to be used in conjunction 
 with `TabItemViewControllerRouter` to easily implement something like `UITabController` without any boilerplate, 
 allowing you to only focus on your custom UI.
 
 When a button is selected, its selection state is updated (as is the selection state of the previously selected button). 
 Additionally, a new selected tab bar item is vended, which your UI should react to in order to update which view 
 controller is currently being shown on screen.
 */
public final class TabItemSelectionState {
    // MARK: - Public mutable state
    
    public let selectedTabItem: Observable<TabItem>
    
    // MARK: - Private mutable state
    
    private var selectedButton: UIControl
    
    // MARK: - Private immutable state
    
    private let buttonsToTabItems: [UIControl: TabItem]
    
    // MARK: - Initialization
    
    /**
     Create a new selection state i nstance.
     
     - parameter buttons:  Buttons. Must not be empty.
     - parameter tabItems: Tab items. Must not be empty and must have the same `.count` as `buttons`.
     
     - returns: New instance
     */
    public init(buttons: [UIControl], tabItems: [TabItem]) {
        assert(buttons.count == tabItems.count)
        guard let firstTabItem = tabItems.first else { fatalError("Need at least one tab bar item") }
        guard let firstButton = buttons.first else { fatalError("Need at least one button") }
        
        selectedTabItem = Observable(firstTabItem)
        
        selectedButton = firstButton
        selectedButton.selected = true
        
        buttonsToTabItems = Dictionary<UIControl, TabItem>(zip(buttons, tabItems))
    }
    
    // MARK: - Public
    
    public func selectButton(button: UIControl) {
        guard button != selectedButton else { return }
        guard let tabItem = buttonsToTabItems[button] else { fatalError("Unknown button") }
        
        selectedButton.selected = false
        selectedButton = button
        selectedButton.selected = true
        
        selectedTabItem.value = tabItem
    }
}
/**
 *  A class that can be part of a tabbed navigational interface (expected to be a `UIViewController` but can also be a 
 *  coordinator that proxies through to an underlying controller).
 */
public protocol TabComponent {
    /// The tab metadata
    var tabItem: TabItem { get }

    var viewController: UIViewController { get }
}

/**
 *  Only needed because Swift doesn’t currently have a way to allow you to specify that an instance both descends from 
 *  `UIViewController` and also conforms to a protocol.
 */
public extension TabComponent where Self: UIViewController {
    var viewController: UIViewController {
        return self
    }
}

/**
 *  Basically the same thing as `UITabBarItem` but a custom one that we control. For now, the only real reason we’re 
 *  using this custom class is to make `badgeValue` observable but we may add more stuff in the future, e.g. 
 * `badgeColor`.
 */
public final class TabItem: Hashable, Equatable {
    public let title: String
    public let badgeValue: Observable<Int?>
    
    // MARK: - Hashable
    
    public var hashValue: Int {
        return title.hashValue
    }
    
    // MARK: - Initialization
    
    public convenience init(title: String, badgeValue: Int? = nil) {
        self.init(title: title, badgeValue: Observable(badgeValue))
    }
    
    public init(title: String, badgeValue: Observable<Int?>) {
        self.title = title
        self.badgeValue = badgeValue
    }
}

public func ==(lhs: TabItem, rhs: TabItem) -> Bool {
    return lhs.title == rhs.title
}