Last active
December 22, 2020 15:38
-
-
Save irace/d0fc761e3329897186260ab27723065a to your computer and use it in GitHub Desktop.
Easily roll your own `UITabBarController` alternatives. Here’s all the logic you need without assuming anything about your UI.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment