Compare commits

..

No commits in common. "8caf93bf0a514c9a88ff46d672b55c1927788a2e" and "71a57e9859646c924346fe1c42d3bf3f50300833" have entirely different histories.

65 changed files with 115 additions and 381 deletions

View File

@ -304,7 +304,6 @@
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; }; D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; };
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; }; D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; };
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; }; D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; };
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; }; D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; };
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; }; D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; }; D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
@ -560,7 +559,7 @@
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; }; D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; }; D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = "<group>"; }; D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = "<group>"; };
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pachyderm; path = Packages/Pachyderm; sourceTree = "<group>"; }; D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; }; D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; }; D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; }; D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
@ -697,7 +696,6 @@
D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TuskerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TuskerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerUITests.swift; sourceTree = "<group>"; }; D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerUITests.swift; sourceTree = "<group>"; };
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; };
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; }; D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; }; D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; }; D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
@ -1366,7 +1364,6 @@
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */, D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */, D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */, D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */,
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */, D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
D620483723D38190008A63EF /* StatusContentTextView.swift */, D620483723D38190008A63EF /* StatusContentTextView.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */, 04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
@ -1412,6 +1409,7 @@
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */, D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */, D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */, D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */, D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */, D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
D61F759129365C6C00C0B37F /* CollectionViewController.swift */, D61F759129365C6C00C0B37F /* CollectionViewController.swift */,
@ -1485,7 +1483,6 @@
D6F1F84E2193B9BE00F5FE67 /* Caching */, D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6370B9924421FE00092A7FF /* CoreData */, D6370B9924421FE00092A7FF /* CoreData */,
D667E5F62135C2ED0057A976 /* Extensions */, D667E5F62135C2ED0057A976 /* Extensions */,
D6D706A12947D954000827ED /* Gestures */,
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */, D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */,
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */, D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
D61959D2241E846D00A37B8E /* Models */, D61959D2241E846D00A37B8E /* Models */,
@ -1526,14 +1523,6 @@
path = TuskerUITests; path = TuskerUITests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D6D706A12947D954000827ED /* Gestures */ = {
isa = PBXGroup;
children = (
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
);
path = Gestures;
sourceTree = "<group>";
};
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = { D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -2046,7 +2035,6 @@
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */, D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */, D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */, D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */, D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class NotificationsPageViewController: SegmentedPageViewController<NotificationsPageViewController.Page> { class NotificationsPageViewController: SegmentedPageViewController {
private let notificationsTitle = NSLocalizedString("Notifications", comment: "notifications tab title") private let notificationsTitle = NSLocalizedString("Notifications", comment: "notifications tab title")
private let mentionsTitle = NSLocalizedString("Mentions", comment: "mentions tab title") private let mentionsTitle = NSLocalizedString("Mentions", comment: "mentions tab title")
@ -30,9 +30,12 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
mentions.title = mentionsTitle mentions.title = mentionsTitle
mentions.userActivity = UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly) mentions.userActivity = UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
super.init(pages: [ super.init(titles: [
(.all, notificationsTitle, notifications), notificationsTitle,
(.mentions, mentionsTitle, mentions), mentionsTitle
], pageControllers: [
notifications,
mentions
]) ])
title = notificationsTitle title = notificationsTitle
@ -50,20 +53,15 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
} }
func selectMode(_ mode: NotificationsMode) { func selectMode(_ mode: NotificationsMode) {
let page: Page let index: Int
switch mode { switch mode {
case .allNotifications: case .allNotifications:
page = .all index = 0
case .mentionsOnly: case .mentionsOnly:
page = .mentions index = 1
} }
segmentedControl.setSelectedOption(page, animated: false) segmentedControl.selectedSegmentIndex = index
selectPage(page, animated: false) selectPage(at: index, animated: false)
}
enum Page {
case all
case mentions
} }
} }

View File

@ -142,7 +142,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
let view = ProfileHeaderView.create() let view = ProfileHeaderView.create()
view.delegate = self.profileHeaderDelegate view.delegate = self.profileHeaderDelegate
view.updateUI(for: id) view.updateUI(for: id)
view.pagesSegmentedControl.setSelectedOption(self.owner!.currentPage, animated: false) view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0
cell.addHeader(view) cell.addHeader(view)
case .useExistingView(let view): case .useExistingView(let view):
cell.addHeader(view) cell.addHeader(view)

View File

@ -29,11 +29,7 @@ class ProfileViewController: UIViewController {
} }
private(set) var currentIndex: Int! private(set) var currentIndex: Int!
private let pages = [Page.posts, .postsAndReplies, .media]
private var pageControllers: [ProfileStatusesViewController]! private var pageControllers: [ProfileStatusesViewController]!
var currentPage: Page {
pages[currentIndex]
}
var currentViewController: ProfileStatusesViewController { var currentViewController: ProfileStatusesViewController {
pageControllers[currentIndex] pageControllers[currentIndex]
} }
@ -287,14 +283,6 @@ class ProfileViewController: UIViewController {
} }
} }
extension ProfileViewController {
enum Page: Hashable {
case posts
case postsAndReplies
case media
}
}
extension ProfileViewController { extension ProfileViewController {
enum State { enum State {
case idle case idle
@ -310,25 +298,24 @@ extension ProfileViewController: ToastableViewController {
} }
extension ProfileViewController: ProfileHeaderViewDelegate { extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: Page) { func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
guard case .idle = state else { guard case .idle = state else {
headerView.pagesSegmentedControl.setSelectedOption(currentPage, animated: false)
return return
} }
selectPage(at: pages.firstIndex(of: newPage)!, animated: true) selectPage(at: newIndex, animated: true)
} }
} }
extension ProfileViewController: TabbedPageViewController { extension ProfileViewController: TabbedPageViewController {
func selectNextPage() { func selectNextPage() {
guard currentIndex < pageControllers.count - 1 else { return } guard currentIndex < pageControllers.count - 1 else { return }
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex + 1], animated: true) currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex + 1
selectPage(at: currentIndex + 1, animated: true) selectPage(at: currentIndex + 1, animated: true)
} }
func selectPrevPage() { func selectPrevPage() {
guard currentIndex > 0 else { return } guard currentIndex > 0 else { return }
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex - 1], animated: true) currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex - 1
selectPage(at: currentIndex - 1, animated: true) selectPage(at: currentIndex - 1, animated: true)
} }
} }

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import SwiftUI import SwiftUI
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> { class TimelinesPageViewController: SegmentedPageViewController {
private let homeTitle = NSLocalizedString("Home", comment: "home timeline tab title") private let homeTitle = NSLocalizedString("Home", comment: "home timeline tab title")
private let federatedTitle = NSLocalizedString("Federated", comment: "federated timeline tab title") private let federatedTitle = NSLocalizedString("Federated", comment: "federated timeline tab title")
@ -29,10 +29,14 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController) let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
local.title = localTitle local.title = localTitle
super.init(pages: [ super.init(titles: [
(.home, "Home", home), homeTitle,
(.local, "Local", local), federatedTitle,
(.federated, "Federated", federated), localTitle
], pageControllers: [
home,
federated,
local
]) ])
title = homeTitle title = homeTitle
@ -71,30 +75,24 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
guard let timeline = UserActivityManager.getTimeline(from: activity) else { guard let timeline = UserActivityManager.getTimeline(from: activity) else {
return return
} }
let page: Page let index: Int
switch timeline { switch timeline {
case .home: case .home:
page = .home index = 0
case .public(local: false): case .public(local: false):
page = .federated index = 1
case .public(local: true): case .public(local: true):
page = .local index = 2
default: default:
return return
} }
selectPage(page, animated: false) selectPage(at: index, animated: false)
let timelineVC = pageControllers[currentIndex] as! TimelineViewController let timelineVC = pageControllers[index] as! TimelineViewController
timelineVC.restoreActivity(activity) timelineVC.restoreActivity(activity)
} }
@objc private func filtersPressed() { @objc private func filtersPressed() {
present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true) present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true)
} }
enum Page: Hashable {
case home
case local
case federated
}
} }

View File

@ -8,45 +8,33 @@
import UIKit import UIKit
class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController { class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDelegate {
let pages: [Page] let titles: [String]
let pageControllers: [UIViewController] let pageControllers: [UIViewController]
private var initialPage: Page private var initialIndex = 0
private var currentPage: Page private(set) var currentIndex = 0
var currentIndex: Int {
pages.firstIndex(of: currentPage)!
}
let segmentedControl = ScrollingSegmentedControl<Page>() var segmentedControl: UISegmentedControl!
init(pages: [(Page, String, UIViewController)]) { init(titles: [String], pageControllers: [UIViewController]) {
precondition(!pages.isEmpty) precondition(!pageControllers.isEmpty)
self.pages = pages.map(\.0) self.titles = titles
self.pageControllers = pages.map(\.2) self.pageControllers = pageControllers
initialPage = self.pages.first!
currentPage = self.pages.first!
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
self.delegate = self
// this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView // this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView
// before the view has necessarily loaded // before the view has necessarily loaded
segmentedControl.options = pages.map { segmentedControl = UISegmentedControl(items: titles)
.init(value: $0.0, name: $0.1) segmentedControl.addTarget(self, action: #selector(segmentedControlChanged), for: .valueChanged)
}
segmentedControl.didSelectOption = { [unowned self] option in
if let option {
self.selectPage(option, animated: true)
}
}
// TODO: double check this with the custom segmented control
// the segemented control itself is only focusable when VoiceOver is in Group navigation mode, // 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 // so make it clear that to switch tabs the user needs to enter the group
segmentedControl.accessibilityHint = "Enter group to select timeline" segmentedControl.accessibilityHint = "Enter group to select timeline"
segmentedControl.setSelectedOption(segmentedControl.options.first!.value, animated: false)
navigationItem.titleView = segmentedControl navigationItem.titleView = segmentedControl
} }
@ -59,7 +47,7 @@ class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageV
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
selectPage(initialPage, animated: false) selectPage(at: initialIndex, animated: false)
addKeyCommand(MenuController.prevSubTabCommand) addKeyCommand(MenuController.prevSubTabCommand)
addKeyCommand(MenuController.nextSubTabCommand) addKeyCommand(MenuController.nextSubTabCommand)
@ -72,36 +60,28 @@ class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageV
} }
} }
func selectPage(_ page: Page, animated: Bool) { func selectPage(at index: Int, animated: Bool) {
guard pages.contains(page) else {
fatalError("invalid page \(page) that is not in SegmentedPageViewController.pages")
}
guard isViewLoaded else { guard isViewLoaded else {
initialPage = page initialIndex = index
return return
} }
let prevIndex = currentIndex let direction: UIPageViewController.NavigationDirection = index - currentIndex > 0 ? .forward : .reverse
currentPage = page setViewControllers([pageControllers[index]], direction: direction, animated: animated)
let index = pages.firstIndex(of: page)! navigationItem.title = pageControllers[index].title
let newController = pageControllers[index] currentIndex = index
segmentedControl.selectedSegmentIndex = index
let direction: UIPageViewController.NavigationDirection = index - prevIndex > 0 ? .forward : .reverse
setViewControllers([newController], direction: direction, animated: animated)
navigationItem.title = newController.title
segmentedControl.setSelectedOption(page, animated: animated)
} }
// MARK: TabbedPageViewController @objc func segmentedControlChanged() {
selectPage(at: segmentedControl.selectedSegmentIndex, animated: true)
func selectNextPage() { UIImpactFeedbackGenerator(style: .light).impactOccurred()
guard currentIndex < pageControllers.count - 1 else { return }
selectPage(pages[currentIndex + 1], animated: true)
} }
func selectPrevPage() { // MARK: - Page View Controller Delegate
guard currentIndex > 0 else { return } func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
selectPage(pages[currentIndex - 1], animated: true) currentIndex = pageControllers.firstIndex(of: viewControllers!.first!)!
segmentedControl.selectedSegmentIndex = currentIndex
navigationItem.title = viewControllers!.first!.title
} }
} }
@ -114,6 +94,18 @@ extension SegmentedPageViewController: TabBarScrollableViewController {
} }
} }
extension SegmentedPageViewController: TabbedPageViewController {
func selectNextPage() {
guard currentIndex < pageControllers.count - 1 else { return }
selectPage(at: currentIndex + 1, animated: true)
}
func selectPrevPage() {
guard currentIndex > 0 else { return }
selectPage(at: currentIndex - 1, animated: true)
}
}
extension SegmentedPageViewController: BackgroundableViewController { extension SegmentedPageViewController: BackgroundableViewController {
func sceneDidEnterBackground() { func sceneDidEnterBackground() {
if let current = pageControllers[currentIndex] as? BackgroundableViewController { if let current = pageControllers[currentIndex] as? BackgroundableViewController {

View File

@ -217,19 +217,20 @@ class UserActivityManager {
switch timeline { switch timeline {
case .home, .public(true), .public(false): case .home, .public(true), .public(false):
navigationController.popToRootViewController(animated: false) navigationController.popToRootViewController(animated: false)
let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController let rootController = navigationController.viewControllers.first! as! SegmentedPageViewController
let page: TimelinesPageViewController.Page let index: Int
switch timeline { switch timeline {
case .home: case .home:
page = .home index = 0
case .public(local: false): case .public(false):
page = .federated index = 1
case .public(local: true): case .public(true):
page = .local index = 2
default: default:
fatalError() fatalError()
} }
rootController.selectPage(page, animated: false) rootController.segmentedControl.selectedSegmentIndex = index
rootController.selectPage(at: index, animated: false)
default: default:
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController) let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
navigationController.pushViewController(timeline, animated: false) navigationController.pushViewController(timeline, animated: false)

View File

@ -11,7 +11,7 @@ import Pachyderm
import Combine import Combine
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider { protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider {
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: ProfileViewController.Page) func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int)
} }
class ProfileHeaderView: UIView { class ProfileHeaderView: UIView {
@ -35,11 +35,10 @@ class ProfileHeaderView: UIView {
@IBOutlet weak var displayNameLabel: EmojiLabel! @IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var usernameLabel: UILabel! @IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var lockImageView: UIImageView! @IBOutlet weak var lockImageView: UIImageView!
@IBOutlet weak var vStack: UIStackView!
@IBOutlet weak var relationshipLabel: UILabel! @IBOutlet weak var relationshipLabel: UILabel!
@IBOutlet weak var noteTextView: StatusContentTextView! @IBOutlet weak var noteTextView: StatusContentTextView!
@IBOutlet weak var fieldsView: ProfileFieldsView! @IBOutlet weak var fieldsView: ProfileFieldsView!
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>! @IBOutlet weak var pagesSegmentedControl: UISegmentedControl!
var accountID: String! var accountID: String!
@ -84,22 +83,6 @@ class ProfileHeaderView: UIView {
noteTextView.defaultFont = .preferredFont(forTextStyle: .body) noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
noteTextView.adjustsFontForContentSizeCategory = true noteTextView.adjustsFontForContentSizeCategory = true
pagesSegmentedControl = ScrollingSegmentedControl(frame: .zero)
pagesSegmentedControl.options = [
.init(value: .posts, name: "Posts"),
.init(value: .postsAndReplies, name: "Posts and Replies"),
.init(value: .media, name: "Media"),
]
pagesSegmentedControl.setSelectedOption(.posts, animated: false)
pagesSegmentedControl.didSelectOption = { [unowned self] newPage in
if let newPage {
self.delegate?.profileHeader(self, selectedPageChangedTo: newPage)
}
}
vStack.addArrangedSubview(pagesSegmentedControl)
// equal inset on both sides, the leading inset is applied to the vStack
pagesSegmentedControl.widthAnchor.constraint(equalTo: vStack.widthAnchor, constant: -16).isActive = true
// the segemented control itself is only focusable when VoiceOver is in Group navigation mode, // 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 // so make it clear that to switch tabs the user needs to enter the group
pagesSegmentedControl.accessibilityHint = "Enter group to select scope" pagesSegmentedControl.accessibilityHint = "Enter group to select scope"
@ -281,6 +264,11 @@ class ProfileHeaderView: UIView {
delegate?.showLoadingLargeImage(url: header, cache: .headers, description: nil, animatingFrom: headerImageView) delegate?.showLoadingLargeImage(url: header, cache: .headers, description: nil, animatingFrom: headerImageView)
} }
@IBAction func postsSegmentedControlChanged(_ sender: UISegmentedControl) {
delegate?.profileHeader(self, selectedPostsIndexChangedTo: sender.selectedSegmentIndex)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
} }
extension ProfileHeaderView: UIPointerInteractionDelegate { extension ProfileHeaderView: UIPointerInteractionDelegate {

View File

@ -69,22 +69,42 @@
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" horizontalHuggingPriority="249" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1O8-2P-Gbf" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target"> <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" horizontalHuggingPriority="249" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1O8-2P-Gbf" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="382" height="460"/> <rect key="frame" x="0.0" y="0.0" width="382" height="259.5"/>
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string> <string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" systemColor="labelColor"/> <color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <fontDescription key="fontDescription" type="system" pointSize="17"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView> </textView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target"> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="468" width="398" height="128"/> <rect key="frame" x="0.0" y="267.5" width="398" height="128"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints> <constraints>
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/> <constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
</constraints> </constraints>
</view> </view>
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="n1M-vM-Cj0">
<rect key="frame" x="0.0" y="403.5" width="382" height="185"/>
<segments>
<segment title="Posts"/>
<segment title="Posts and Replies"/>
<segment title="Media"/>
</segments>
<connections>
<action selector="postsSegmentedControlChanged:" destination="iN0-l3-epB" eventType="valueChanged" id="D6y-ZM-DwU"/>
</connections>
</segmentedControl>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ja-fK-Fqz">
<rect key="frame" x="0.0" y="595.5" width="398" height="0.5"/>
<color key="backgroundColor" systemColor="separatorColor"/>
<constraints>
<constraint firstAttribute="height" constant="0.5" id="VwS-gV-q8M"/>
</constraints>
</view>
</subviews> </subviews>
<constraints> <constraints>
<constraint firstItem="vKC-m1-Sbs" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="0dI-ax-7eI"/> <constraint firstItem="vKC-m1-Sbs" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="0dI-ax-7eI"/>
<constraint firstItem="n1M-vM-Cj0" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="9Ds-zl-acc"/>
<constraint firstItem="5ja-fK-Fqz" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="azv-le-93y"/>
<constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="hnA-3G-B9B"/> <constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="hnA-3G-B9B"/>
</constraints> </constraints>
</stackView> </stackView>
@ -104,13 +124,6 @@
</imageView> </imageView>
</subviews> </subviews>
</stackView> </stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ja-fK-Fqz">
<rect key="frame" x="16" y="861.5" width="398" height="0.5"/>
<color key="backgroundColor" systemColor="separatorColor"/>
<constraints>
<constraint firstAttribute="height" constant="0.5" id="VwS-gV-q8M"/>
</constraints>
</view>
</subviews> </subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" systemColor="systemBackgroundColor"/>
@ -120,11 +133,9 @@
<constraint firstItem="wT9-2J-uSY" firstAttribute="centerY" secondItem="dgG-dR-lSv" secondAttribute="bottom" id="7gb-T3-Xe7"/> <constraint firstItem="wT9-2J-uSY" firstAttribute="centerY" secondItem="dgG-dR-lSv" secondAttribute="bottom" id="7gb-T3-Xe7"/>
<constraint firstItem="vcl-Gl-kXl" firstAttribute="top" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="8" symbolic="YES" id="7ss-Mf-YYH"/> <constraint firstItem="vcl-Gl-kXl" firstAttribute="top" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="8" symbolic="YES" id="7ss-Mf-YYH"/>
<constraint firstItem="vcl-Gl-kXl" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="8ho-WU-MxW"/> <constraint firstItem="vcl-Gl-kXl" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="8ho-WU-MxW"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="5ja-fK-Fqz" secondAttribute="bottom" id="9ZS-Ey-eKd"/>
<constraint firstItem="jwU-EH-hmC" firstAttribute="top" secondItem="vcl-Gl-kXl" secondAttribute="bottom" id="9lx-Fn-M0U"/> <constraint firstItem="jwU-EH-hmC" firstAttribute="top" secondItem="vcl-Gl-kXl" secondAttribute="bottom" id="9lx-Fn-M0U"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="u4P-3i-gEq" secondAttribute="bottom" id="9zc-N2-mfI"/> <constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="u4P-3i-gEq" secondAttribute="bottom" id="9zc-N2-mfI"/>
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="bottom" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="-8" id="AXS-bG-20Q"/> <constraint firstItem="bRJ-Xf-kc9" firstAttribute="bottom" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="-8" id="AXS-bG-20Q"/>
<constraint firstAttribute="trailing" secondItem="5ja-fK-Fqz" secondAttribute="trailing" id="EMk-dp-yJV"/>
<constraint firstItem="jwU-EH-hmC" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="TkY-oK-if4" secondAttribute="bottom" id="HBR-rg-RxO"/> <constraint firstItem="jwU-EH-hmC" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="TkY-oK-if4" secondAttribute="bottom" id="HBR-rg-RxO"/>
<constraint firstItem="dgG-dR-lSv" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="VD1-yc-KSa"/> <constraint firstItem="dgG-dR-lSv" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="VD1-yc-KSa"/>
<constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/> <constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/>
@ -132,7 +143,6 @@
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="vcl-Gl-kXl" secondAttribute="trailing" constant="16" id="e38-Od-kPg"/> <constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="vcl-Gl-kXl" secondAttribute="trailing" constant="16" id="e38-Od-kPg"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="hgl-UR-o3W"/> <constraint firstItem="u4P-3i-gEq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="hgl-UR-o3W"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/> <constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/>
<constraint firstItem="5ja-fK-Fqz" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="jPG-WM-9km"/>
<constraint firstItem="jwU-EH-hmC" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="oGR-6M-gdd"/> <constraint firstItem="jwU-EH-hmC" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="oGR-6M-gdd"/>
<constraint firstAttribute="trailing" secondItem="u4P-3i-gEq" secondAttribute="trailing" id="ph6-NT-A02"/> <constraint firstAttribute="trailing" secondItem="u4P-3i-gEq" secondAttribute="trailing" id="ph6-NT-A02"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="wT9-2J-uSY" secondAttribute="bottom" priority="999" constant="8" id="tKQ-6d-Z55"/> <constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="wT9-2J-uSY" secondAttribute="bottom" priority="999" constant="8" id="tKQ-6d-Z55"/>
@ -147,9 +157,9 @@
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/> <outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
<outlet property="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/> <outlet property="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/>
<outlet property="noteTextView" destination="1O8-2P-Gbf" id="yss-zZ-uQ5"/> <outlet property="noteTextView" destination="1O8-2P-Gbf" id="yss-zZ-uQ5"/>
<outlet property="pagesSegmentedControl" destination="n1M-vM-Cj0" id="TCU-ku-YZN"/>
<outlet property="relationshipLabel" destination="UF8-nI-KVj" id="dTe-DQ-eJV"/> <outlet property="relationshipLabel" destination="UF8-nI-KVj" id="dTe-DQ-eJV"/>
<outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/> <outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/>
<outlet property="vStack" destination="u4P-3i-gEq" id="EUC-d2-cQC"/>
</connections> </connections>
<point key="canvasLocation" x="-590" y="117"/> <point key="canvasLocation" x="-590" y="117"/>
</view> </view>

View File

@ -1,228 +0,0 @@
//
// ScrollingSegmentedControl.swift
// Tusker
//
// Created by Shadowfacts on 12/11/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecognizerDelegate, UIPointerInteractionDelegate {
private(set) var selectedOption: Value?
var options: [Option] = [] {
didSet {
createOptionViews()
}
}
var didSelectOption: ((Value?) -> Void)?
private let optionsStack = UIStackView()
private let selectedIndicatorView = UIView()
private var selectedIndicatorViewAlignmentConstraints: [NSLayoutConstraint] = []
private var changeSelectionPanRecognizer: UIGestureRecognizer!
private var selectedOptionAtStartOfPan: Value?
private lazy var selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
override var intrinsicContentSize: CGSize {
let buttonWidths = optionsStack.arrangedSubviews.map(\.intrinsicContentSize.width).reduce(0, +)
let spacing = (CGFloat(optionsStack.arrangedSubviews.count) - 1) * 8
// add 16 to account for the spacing around optionsStack
return CGSize(width: buttonWidths + spacing + 16, height: 44)
}
override init(frame: CGRect) {
super.init(frame: frame)
showsHorizontalScrollIndicator = false
optionsStack.axis = .horizontal
optionsStack.spacing = 8
optionsStack.distribution = .fillProportionally
optionsStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(optionsStack)
NSLayoutConstraint.activate([
optionsStack.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor, constant: 8),
optionsStack.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor, constant: -8),
optionsStack.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
optionsStack.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
optionsStack.heightAnchor.constraint(equalTo: heightAnchor),
// add 16 to account for the spacing around optionsStack
widthAnchor.constraint(lessThanOrEqualTo: optionsStack.widthAnchor, constant: 16),
])
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
self.changeSelectionPanRecognizer = panRecognizer
panRecognizer.delegate = self
optionsStack.addGestureRecognizer(panRecognizer)
optionsStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(optionTapped)))
optionsStack.addInteraction(UIPointerInteraction(delegate: self))
self.panGestureRecognizer.delegate = self
selectedIndicatorView.isHidden = true
selectedIndicatorView.backgroundColor = .tintColor
selectedIndicatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(selectedIndicatorView)
NSLayoutConstraint.activate([
selectedIndicatorView.heightAnchor.constraint(equalToConstant: 4),
selectedIndicatorView.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func createOptionViews() {
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for (index, option) in options.enumerated() {
let label = UILabel()
label.text = option.name
label.font = .preferredFont(forTextStyle: .headline)
label.adjustsFontForContentSizeCategory = true
label.textColor = .secondaryLabel
label.textAlignment = .center
label.accessibilityTraits = .button
label.accessibilityLabel = "\(option.name), \(index + 1) of \(options.count)"
optionsStack.addArrangedSubview(label)
}
}
func setSelectedOption(_ value: Value, animated: Bool) {
guard selectedOption != value,
options.contains(where: { $0.value == value }) else {
return
}
if selectedOption != nil {
selectionChangedFeedbackGenerator.selectionChanged()
}
selectedOption = value
didSelectOption?(value)
updateSelectedIndicatorView()
if animated && !selectedIndicatorView.isHidden {
let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 0.8) {
self.layoutIfNeeded()
}
animator.startAnimation()
}
}
private func updateSelectedIndicatorView() {
guard let selectedOption,
let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }) else {
selectedIndicatorView.isHidden = true
return
}
let selectedOptionView = optionsStack.arrangedSubviews[selectedIndex]
selectedIndicatorView.isHidden = false
NSLayoutConstraint.deactivate(selectedIndicatorViewAlignmentConstraints)
selectedIndicatorViewAlignmentConstraints = [
selectedIndicatorView.leadingAnchor.constraint(equalTo: selectedOptionView.leadingAnchor),
selectedIndicatorView.trailingAnchor.constraint(equalTo: selectedOptionView.trailingAnchor),
]
NSLayoutConstraint.activate(selectedIndicatorViewAlignmentConstraints)
for (index, optionView) in optionsStack.arrangedSubviews.enumerated() {
let label = optionView as! UILabel
label.textColor = index == selectedIndex ? .label : .secondaryLabel
label.accessibilityTraits = index == selectedIndex ? [.button, .selected] : .button
}
}
// MARK: Interaction
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let beganOnSelectedOption: Bool
if let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }),
optionsStack.arrangedSubviews[selectedIndex].frame.contains(self.panGestureRecognizer.location(in: optionsStack)) {
beganOnSelectedOption = true
} else {
beganOnSelectedOption = false
}
// only begin changing selection if the gesutre started on the currently selected item
// otherwise, let the scroll view handle things
if gestureRecognizer == self.changeSelectionPanRecognizer {
return beganOnSelectedOption
} else {
return !beganOnSelectedOption
}
}
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
let horizontalLocationInStack = CGPoint(x: recognizer.location(in: optionsStack).x, y: 0)
switch recognizer.state {
case .began:
selectedOptionAtStartOfPan = selectedOption
selectionChangedFeedbackGenerator.prepare()
case .changed:
if updateSelectionFor(location: horizontalLocationInStack) {
selectionChangedFeedbackGenerator.prepare()
}
case .ended:
if let selectedOptionAtStartOfPan {
self.selectedOptionAtStartOfPan = nil
if let selectedOption,
selectedOptionAtStartOfPan != selectedOption {
didSelectOption?(selectedOption)
}
}
default:
break
}
}
@objc private func optionTapped(_ recognizer: UITapGestureRecognizer) {
let location = recognizer.location(in: optionsStack)
if updateSelectionFor(location: location) {
didSelectOption?(selectedOption!)
}
}
private func updateSelectionFor(location: CGPoint) -> Bool {
for (index, optionView) in optionsStack.arrangedSubviews.enumerated() where optionView.frame.contains(location) {
if selectedOption != options[index].value {
selectedOption = options[index].value
let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 0.8) {
self.updateSelectedIndicatorView()
self.scrollRectToVisible(optionView.frame.insetBy(dx: -16, dy: 0), animated: false)
self.layoutIfNeeded()
}
animator.startAnimation()
selectionChangedFeedbackGenerator.selectionChanged()
return true
} else {
return false
}
}
return false
}
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
func distanceToAnyEdge(_ view: UIView) -> CGFloat {
min(abs(view.frame.minX - request.location.x), abs(view.frame.maxX - request.location.x))
}
let (view, index, _) = optionsStack.arrangedSubviews.enumerated().map { ($0.1, $0.0, distanceToAnyEdge($0.1)) }.min(by: { $0.2 < $1.2 })!
return UIPointerRegion(rect: view.frame.insetBy(dx: -8, dy: 0), identifier: index)
}
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
let index = region.identifier as! Int
let optionView = optionsStack.arrangedSubviews[index]
return UIPointerStyle(effect: .hover(UITargetedPreview(view: optionView)))
}
struct Option: Hashable {
let value: Value
let name: String
}
}