Something went wrong. Try again.
A slopfork of Sam Henri Gold's Twitter client, reskinned like Tweetie 2 / Twitter for iPhone circa 2012. Personal use.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132import ColumnNavigationimport ComposableArchitectureimport SafariServicesimport SwiftUI
/// A tab's navigation: one `ColumnNavigationController` whose stack is the root and the path, in every size class./// Horizontally regular, it's Tweetie's stacked columns (each opened thing is a column after the one it came from, the/// strip slides and snaps, columns pull down or off to close, a pull on the root throws them away or opens the/// overview); compact, it's a plain navigation stack, with web pages in Safari's view instead of a column.////// The navigation controller is kept in step with the path: opening adds (or replaces) columns, and a column the/// controller lets go of (a back swipe, a pull down, a throw away, a flick in the overview) leaves the path.////// Columns are hosted straight in UIKit, with no SwiftUI container around the navigation controller: a SwiftUI/// `TabView` (or any host managing toolbars) above it stops the columns' toolbars and titles reaching its bar.@MainActorpackage final class TabStackController: NSObject { /// Twitter for iPad's column: two beside the root's peek on iPhone Duo's inner display. static let columnWidth: CGFloat = 420 /// A web page needs more room than a timeline. static let webPanelWidth: CGFloat = 600
package let navigationController: ColumnNavigationController private let store: StoreOf<TabStack> private var columns: [StackElementID: ColumnHostingController<TabStackPathView>] = [:] private var isAwaitingTransition = false private var observation: ObserveToken?
package init(store: StoreOf<TabStack>, title: LocalizedStringKey) { self.store = store let root = ColumnHostingController(stack: store, id: nil, width: Self.columnWidth) { TabRootView(store: store, title: title) } // Columns put their own (SwiftUI) buttons in iPhone Duo's vertical bar when focused. ColumnSettings.shared.columnsPlaceVerticalBarItems = true navigationController = ColumnNavigationController(rootViewController: root) super.init() navigationController.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { [weak self] (controller: ColumnNavigationController, _) in self?.columnAvailabilityChanged() } // Launched already wide (iPhone Duo open), the size class never changes: the root's first layout says so. root.onLayout = { [weak self] in self?.columnAvailabilityChanged() } observation = observe { [weak self] in guard let self else { return } _ = store.path.ids sync() presentSafariIfNeeded() } }
/// Whether the window is wide enough for columns (otherwise a web page opens in Safari's view). private func columnAvailabilityChanged() { let canShowColumns = navigationController.traitCollection.horizontalSizeClass == .regular if store.canShowColumns != canShowColumns { store.send(.columnAvailabilityChanged(canShowColumns)) } }
/// Makes the controller's stack match the path. Never in the middle of a transition: once it's done. private func sync() { // Whatever's left the path stops at once, before (or while) it animates away: see `contentWasRemoved()`. let ids = Set(store.path.ids) for (id, column) in columns where !ids.contains(id) { column.contentWasRemoved() columns[id] = nil } let controller = navigationController if let transition = controller.transitionCoordinator { guard !isAwaitingTransition else { return } isAwaitingTransition = true transition.animate(alongsideTransition: nil) { [weak self] _ in // After UIKit has let go of whatever the transition removed. Task { @MainActor [weak self] in guard let self else { return } isAwaitingTransition = false sync() } } return } guard let root = controller.viewControllers.first else { return } let stack = [root] + store.path.ids.compactMap(column(for:)) let current = controller.viewControllers guard stack.map(ObjectIdentifier.init) != current.map(ObjectIdentifier.init) else { return } let animated = controller.viewIfLoaded?.window != nil if stack.count < current.count, current.starts(with: stack, by: ===), let last = stack.last { controller.popToViewController(last, animated: animated) } else if stack.count == current.count + 1, stack.starts(with: current, by: ===), let last = stack.last { controller.pushViewController(last, animated: animated) } else { controller.setColumns(stack, animated: animated) } }
private func column(for id: StackElementID) -> ColumnHostingController<TabStackPathView>? { if let column = columns[id] { return column } guard let state = store.path[id: id], let columnStore = store.scope(state: \.path[id: id], action: \.path[id: id]) else { return nil } let column = ColumnHostingController( stack: store, id: id, width: state.isWide ? Self.webPanelWidth : Self.columnWidth ) { TabStackPathView(store: columnStore) } column.onRemoved = { [weak self] in self?.columnWasRemoved(id) } columns[id] = column return column }
/// The controller let go of a column: it, and everything after it, leaves the path (unless the path already let /// go of it first). private func columnWasRemoved(_ id: StackElementID) { columns[id] = nil guard store.path.ids.contains(id) else { return } store.send(.path(.popFrom(id: id))) }
private func presentSafariIfNeeded() { guard let page = store.safari, navigationController.presentedViewController == nil else { return } let safari = SFSafariViewController(url: page.url) safari.delegate = self navigationController.present(safari, animated: true) }}
extension TabStackController: SFSafariViewControllerDelegate { package nonisolated func safariViewControllerDidFinish(_ controller: SFSafariViewController) { MainActor.assumeIsolated { _ = store.send(.safariDismissed) } }}