221 lines
8.1 KiB
Swift
221 lines
8.1 KiB
Swift
//
|
|
// SegmentedPageViewController.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 9/13/19.
|
|
// Copyright © 2019 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
protocol SegmentedPageViewControllerPage: Hashable {
|
|
var segmentedControlTitle: String { get }
|
|
}
|
|
|
|
class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIViewController, UIPageViewControllerDelegate, TabbedPageViewController {
|
|
|
|
private(set) var pages: [Page]!
|
|
private let pageProvider: (Page) -> UIViewController
|
|
private var pageControllers = [Page: UIViewController]()
|
|
|
|
private var initialPage: Page
|
|
private(set) var currentPage: Page
|
|
var currentIndex: Int! {
|
|
pages.firstIndex(of: currentPage)
|
|
}
|
|
var currentViewController: UIViewController!
|
|
|
|
let segmentedControl = ScrollingSegmentedControl<Page>()
|
|
|
|
init(pages: [Page], initialPage: Page? = nil, pageProvider: @escaping (Page) -> UIViewController) {
|
|
precondition(!pages.isEmpty)
|
|
|
|
self.pageProvider = pageProvider
|
|
|
|
self.initialPage = initialPage ?? pages.first!
|
|
currentPage = self.initialPage
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
setPages(pages, animated: false)
|
|
|
|
segmentedControl.didSelectOption = { [unowned self] option in
|
|
if let option {
|
|
self.selectPage(option, animated: true)
|
|
}
|
|
}
|
|
// TODO: the custom segmented control isn't treated as a group and I have no idea how to change that
|
|
// the segemented control itself is only focusable when VoiceOver is in Group navigation mode,
|
|
// so make it clear that to switch tabs the user needs to enter the group
|
|
segmentedControl.accessibilityHint = "Enter group to select timeline"
|
|
segmentedControl.setSelectedOption(self.initialPage, animated: false)
|
|
navigationItem.titleView = segmentedControl
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func setPages(_ pages: [Page], animated: Bool) {
|
|
precondition(!pages.isEmpty)
|
|
|
|
self.pages = pages
|
|
|
|
if !pages.contains(currentPage) {
|
|
selectPage(pages.first!, animated: animated)
|
|
}
|
|
|
|
for key in pageControllers.keys where !pages.contains(key) {
|
|
pageControllers.removeValue(forKey: key)
|
|
}
|
|
|
|
// this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView
|
|
// before the view has necessarily loaded
|
|
segmentedControl.options = pages.map {
|
|
.init(value: $0, name: $0.segmentedControlTitle)
|
|
}
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
view.backgroundColor = .appBackground
|
|
|
|
selectPage(initialPage, animated: false)
|
|
|
|
addKeyCommand(MenuController.prevSubTabCommand)
|
|
addKeyCommand(MenuController.nextSubTabCommand)
|
|
|
|
// disable the transparent nav bar because it gets messy with multiple pages at different scroll positions
|
|
if let nav = navigationController {
|
|
let appearance = UINavigationBarAppearance()
|
|
appearance.configureWithDefaultBackground()
|
|
nav.navigationBar.scrollEdgeAppearance = appearance
|
|
}
|
|
}
|
|
|
|
// Extension point for subclasses
|
|
func configureViewController(_ viewController: UIViewController) {
|
|
}
|
|
|
|
func selectPage(_ page: Page, animated: Bool) {
|
|
guard pages.contains(page) else {
|
|
fatalError("invalid page \(page) that is not in SegmentedPageViewController.pages")
|
|
}
|
|
guard isViewLoaded else {
|
|
initialPage = page
|
|
return
|
|
}
|
|
let direction: AnimationMode
|
|
if let prevIndex = currentIndex {
|
|
let index = pages.firstIndex(of: page)!
|
|
direction = index - prevIndex > 0 ? .forward : .reverse
|
|
} else {
|
|
direction = .none
|
|
}
|
|
|
|
currentPage = page
|
|
let newController: UIViewController
|
|
if let existing = pageControllers[page] {
|
|
newController = existing
|
|
} else {
|
|
newController = pageProvider(page)
|
|
configureViewController(newController)
|
|
pageControllers[page] = newController
|
|
}
|
|
|
|
setViewController(newController, animated: animated ? direction : .none)
|
|
navigationItem.title = newController.title
|
|
|
|
segmentedControl.setSelectedOption(page, animated: animated)
|
|
}
|
|
|
|
private func setViewController(_ newViewController: UIViewController, animated: AnimationMode) {
|
|
guard let currentViewController,
|
|
animated != .none else {
|
|
currentViewController?.removeViewAndController()
|
|
newViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
// don't use embedChild here because it triggers an appearance transition, even though this vc hasn't appeared yet
|
|
addChild(newViewController)
|
|
view.addSubview(newViewController.view)
|
|
NSLayoutConstraint.activate([
|
|
newViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
newViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
newViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
newViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
newViewController.didMove(toParent: self)
|
|
self.currentViewController = newViewController
|
|
return
|
|
}
|
|
guard currentViewController !== newViewController else {
|
|
return
|
|
}
|
|
self.currentViewController = newViewController
|
|
newViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
embedChild(newViewController)
|
|
let direction: CGFloat = animated == .forward ? 1 : -1
|
|
newViewController.view.transform = CGAffineTransform(translationX: direction * view.bounds.width, y: 0)
|
|
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: UISpringTimingParameters(dampingRatio: 1, initialVelocity: .zero))
|
|
animator.addAnimations {
|
|
newViewController.view.transform = .identity
|
|
currentViewController.view.transform = CGAffineTransform(translationX: -1 * direction * self.view.bounds.width, y: 0)
|
|
}
|
|
animator.addCompletion { _ in
|
|
currentViewController.removeViewAndController()
|
|
}
|
|
animator.startAnimation()
|
|
}
|
|
|
|
// MARK: TabbedPageViewController
|
|
|
|
func selectNextPage() {
|
|
guard currentIndex < pages.count - 1 else { return }
|
|
selectPage(pages[currentIndex + 1], animated: true)
|
|
}
|
|
|
|
func selectPrevPage() {
|
|
guard currentIndex > 0 else { return }
|
|
selectPage(pages[currentIndex - 1], animated: true)
|
|
}
|
|
}
|
|
|
|
extension SegmentedPageViewController {
|
|
enum AnimationMode: Equatable {
|
|
case none
|
|
case forward
|
|
case reverse
|
|
}
|
|
}
|
|
|
|
extension SegmentedPageViewController: TabBarScrollableViewController {
|
|
func tabBarScrollToTop() {
|
|
if let scrollableVC = currentViewController as? TabBarScrollableViewController {
|
|
scrollableVC.tabBarScrollToTop()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension SegmentedPageViewController: BackgroundableViewController {
|
|
func sceneDidEnterBackground() {
|
|
if let current = currentViewController as? BackgroundableViewController {
|
|
current.sceneDidEnterBackground()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension SegmentedPageViewController: StatusBarTappableViewController {
|
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
|
if let current = currentViewController as? StatusBarTappableViewController {
|
|
return current.handleStatusBarTapped(xPosition: xPosition)
|
|
}
|
|
return .continue
|
|
}
|
|
}
|
|
|
|
extension SegmentedPageViewController: NestedResponderProvider {
|
|
var innerResponder: UIResponder? {
|
|
currentViewController
|
|
}
|
|
}
|