Skip to content

Instantly share code, notes, and snippets.

@irace
Last active December 22, 2020 15:38
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save irace/d0fc761e3329897186260ab27723065a to your computer and use it in GitHub Desktop.
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.
/**
* 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
}
/**
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
}
}
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()
}
}
}
/**
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 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