Add ScrollingSegmentedControl, and home/notifs/profiles to use it
This commit is contained in:
parent
9c4b68b09e
commit
8caf93bf0a
|
@ -304,6 +304,7 @@
|
|||
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; };
|
||||
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.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 */; };
|
||||
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; };
|
||||
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
|
||||
|
@ -696,6 +697,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1364,6 +1366,7 @@
|
|||
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */,
|
||||
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
||||
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
||||
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */,
|
||||
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
||||
D620483723D38190008A63EF /* StatusContentTextView.swift */,
|
||||
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
|
||||
|
@ -2043,6 +2046,7 @@
|
|||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
||||
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
|
||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
||||
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
|
||||
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
|
||||
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class NotificationsPageViewController: SegmentedPageViewController {
|
||||
class NotificationsPageViewController: SegmentedPageViewController<NotificationsPageViewController.Page> {
|
||||
|
||||
private let notificationsTitle = NSLocalizedString("Notifications", comment: "notifications tab title")
|
||||
private let mentionsTitle = NSLocalizedString("Mentions", comment: "mentions tab title")
|
||||
|
@ -30,12 +30,9 @@ class NotificationsPageViewController: SegmentedPageViewController {
|
|||
mentions.title = mentionsTitle
|
||||
mentions.userActivity = UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
|
||||
|
||||
super.init(titles: [
|
||||
notificationsTitle,
|
||||
mentionsTitle
|
||||
], pageControllers: [
|
||||
notifications,
|
||||
mentions
|
||||
super.init(pages: [
|
||||
(.all, notificationsTitle, notifications),
|
||||
(.mentions, mentionsTitle, mentions),
|
||||
])
|
||||
|
||||
title = notificationsTitle
|
||||
|
@ -53,15 +50,20 @@ class NotificationsPageViewController: SegmentedPageViewController {
|
|||
}
|
||||
|
||||
func selectMode(_ mode: NotificationsMode) {
|
||||
let index: Int
|
||||
let page: Page
|
||||
switch mode {
|
||||
case .allNotifications:
|
||||
index = 0
|
||||
page = .all
|
||||
case .mentionsOnly:
|
||||
index = 1
|
||||
page = .mentions
|
||||
}
|
||||
segmentedControl.selectedSegmentIndex = index
|
||||
selectPage(at: index, animated: false)
|
||||
segmentedControl.setSelectedOption(page, animated: false)
|
||||
selectPage(page, animated: false)
|
||||
}
|
||||
|
||||
enum Page {
|
||||
case all
|
||||
case mentions
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -142,7 +142,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
let view = ProfileHeaderView.create()
|
||||
view.delegate = self.profileHeaderDelegate
|
||||
view.updateUI(for: id)
|
||||
view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0
|
||||
view.pagesSegmentedControl.setSelectedOption(self.owner!.currentPage, animated: false)
|
||||
cell.addHeader(view)
|
||||
case .useExistingView(let view):
|
||||
cell.addHeader(view)
|
||||
|
|
|
@ -29,7 +29,11 @@ class ProfileViewController: UIViewController {
|
|||
}
|
||||
|
||||
private(set) var currentIndex: Int!
|
||||
private let pages = [Page.posts, .postsAndReplies, .media]
|
||||
private var pageControllers: [ProfileStatusesViewController]!
|
||||
var currentPage: Page {
|
||||
pages[currentIndex]
|
||||
}
|
||||
var currentViewController: ProfileStatusesViewController {
|
||||
pageControllers[currentIndex]
|
||||
}
|
||||
|
@ -283,6 +287,14 @@ class ProfileViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController {
|
||||
enum Page: Hashable {
|
||||
case posts
|
||||
case postsAndReplies
|
||||
case media
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController {
|
||||
enum State {
|
||||
case idle
|
||||
|
@ -298,24 +310,25 @@ extension ProfileViewController: ToastableViewController {
|
|||
}
|
||||
|
||||
extension ProfileViewController: ProfileHeaderViewDelegate {
|
||||
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
|
||||
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: Page) {
|
||||
guard case .idle = state else {
|
||||
headerView.pagesSegmentedControl.setSelectedOption(currentPage, animated: false)
|
||||
return
|
||||
}
|
||||
selectPage(at: newIndex, animated: true)
|
||||
selectPage(at: pages.firstIndex(of: newPage)!, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: TabbedPageViewController {
|
||||
func selectNextPage() {
|
||||
guard currentIndex < pageControllers.count - 1 else { return }
|
||||
currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex + 1
|
||||
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex + 1], animated: true)
|
||||
selectPage(at: currentIndex + 1, animated: true)
|
||||
}
|
||||
|
||||
func selectPrevPage() {
|
||||
guard currentIndex > 0 else { return }
|
||||
currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex - 1
|
||||
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex - 1], animated: true)
|
||||
selectPage(at: currentIndex - 1, animated: true)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
class TimelinesPageViewController: SegmentedPageViewController {
|
||||
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> {
|
||||
|
||||
private let homeTitle = NSLocalizedString("Home", comment: "home timeline tab title")
|
||||
private let federatedTitle = NSLocalizedString("Federated", comment: "federated timeline tab title")
|
||||
|
@ -29,14 +29,10 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
|||
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
|
||||
local.title = localTitle
|
||||
|
||||
super.init(titles: [
|
||||
homeTitle,
|
||||
federatedTitle,
|
||||
localTitle
|
||||
], pageControllers: [
|
||||
home,
|
||||
federated,
|
||||
local
|
||||
super.init(pages: [
|
||||
(.home, "Home", home),
|
||||
(.local, "Local", local),
|
||||
(.federated, "Federated", federated),
|
||||
])
|
||||
|
||||
title = homeTitle
|
||||
|
@ -75,19 +71,19 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
|||
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
|
||||
return
|
||||
}
|
||||
let index: Int
|
||||
let page: Page
|
||||
switch timeline {
|
||||
case .home:
|
||||
index = 0
|
||||
page = .home
|
||||
case .public(local: false):
|
||||
index = 1
|
||||
page = .federated
|
||||
case .public(local: true):
|
||||
index = 2
|
||||
page = .local
|
||||
default:
|
||||
return
|
||||
}
|
||||
selectPage(at: index, animated: false)
|
||||
let timelineVC = pageControllers[index] as! TimelineViewController
|
||||
selectPage(page, animated: false)
|
||||
let timelineVC = pageControllers[currentIndex] as! TimelineViewController
|
||||
timelineVC.restoreActivity(activity)
|
||||
}
|
||||
|
||||
|
@ -95,4 +91,10 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
|||
present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true)
|
||||
}
|
||||
|
||||
enum Page: Hashable {
|
||||
case home
|
||||
case local
|
||||
case federated
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -8,33 +8,45 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDelegate {
|
||||
class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController {
|
||||
|
||||
let titles: [String]
|
||||
let pages: [Page]
|
||||
let pageControllers: [UIViewController]
|
||||
|
||||
private var initialIndex = 0
|
||||
private(set) var currentIndex = 0
|
||||
private var initialPage: Page
|
||||
private var currentPage: Page
|
||||
var currentIndex: Int {
|
||||
pages.firstIndex(of: currentPage)!
|
||||
}
|
||||
|
||||
var segmentedControl: UISegmentedControl!
|
||||
let segmentedControl = ScrollingSegmentedControl<Page>()
|
||||
|
||||
init(titles: [String], pageControllers: [UIViewController]) {
|
||||
precondition(!pageControllers.isEmpty)
|
||||
init(pages: [(Page, String, UIViewController)]) {
|
||||
precondition(!pages.isEmpty)
|
||||
|
||||
self.titles = titles
|
||||
self.pageControllers = pageControllers
|
||||
self.pages = pages.map(\.0)
|
||||
self.pageControllers = pages.map(\.2)
|
||||
|
||||
initialPage = self.pages.first!
|
||||
currentPage = self.pages.first!
|
||||
|
||||
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
|
||||
// before the view has necessarily loaded
|
||||
segmentedControl = UISegmentedControl(items: titles)
|
||||
segmentedControl.addTarget(self, action: #selector(segmentedControlChanged), for: .valueChanged)
|
||||
segmentedControl.options = pages.map {
|
||||
.init(value: $0.0, name: $0.1)
|
||||
}
|
||||
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,
|
||||
// so make it clear that to switch tabs the user needs to enter the group
|
||||
segmentedControl.accessibilityHint = "Enter group to select timeline"
|
||||
segmentedControl.setSelectedOption(segmentedControl.options.first!.value, animated: false)
|
||||
navigationItem.titleView = segmentedControl
|
||||
}
|
||||
|
||||
|
@ -47,7 +59,7 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
|||
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
selectPage(at: initialIndex, animated: false)
|
||||
selectPage(initialPage, animated: false)
|
||||
|
||||
addKeyCommand(MenuController.prevSubTabCommand)
|
||||
addKeyCommand(MenuController.nextSubTabCommand)
|
||||
|
@ -60,28 +72,36 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
|||
}
|
||||
}
|
||||
|
||||
func selectPage(at index: Int, animated: Bool) {
|
||||
func selectPage(_ page: Page, animated: Bool) {
|
||||
guard pages.contains(page) else {
|
||||
fatalError("invalid page \(page) that is not in SegmentedPageViewController.pages")
|
||||
}
|
||||
guard isViewLoaded else {
|
||||
initialIndex = index
|
||||
initialPage = page
|
||||
return
|
||||
}
|
||||
let direction: UIPageViewController.NavigationDirection = index - currentIndex > 0 ? .forward : .reverse
|
||||
setViewControllers([pageControllers[index]], direction: direction, animated: animated)
|
||||
navigationItem.title = pageControllers[index].title
|
||||
currentIndex = index
|
||||
segmentedControl.selectedSegmentIndex = index
|
||||
let prevIndex = currentIndex
|
||||
currentPage = page
|
||||
let index = pages.firstIndex(of: page)!
|
||||
let newController = pageControllers[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)
|
||||
}
|
||||
|
||||
@objc func segmentedControlChanged() {
|
||||
selectPage(at: segmentedControl.selectedSegmentIndex, animated: true)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
// MARK: TabbedPageViewController
|
||||
|
||||
func selectNextPage() {
|
||||
guard currentIndex < pageControllers.count - 1 else { return }
|
||||
selectPage(pages[currentIndex + 1], animated: true)
|
||||
}
|
||||
|
||||
// MARK: - Page View Controller Delegate
|
||||
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||
currentIndex = pageControllers.firstIndex(of: viewControllers!.first!)!
|
||||
segmentedControl.selectedSegmentIndex = currentIndex
|
||||
navigationItem.title = viewControllers!.first!.title
|
||||
func selectPrevPage() {
|
||||
guard currentIndex > 0 else { return }
|
||||
selectPage(pages[currentIndex - 1], animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -94,18 +114,6 @@ 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 {
|
||||
func sceneDidEnterBackground() {
|
||||
if let current = pageControllers[currentIndex] as? BackgroundableViewController {
|
||||
|
|
|
@ -217,20 +217,19 @@ class UserActivityManager {
|
|||
switch timeline {
|
||||
case .home, .public(true), .public(false):
|
||||
navigationController.popToRootViewController(animated: false)
|
||||
let rootController = navigationController.viewControllers.first! as! SegmentedPageViewController
|
||||
let index: Int
|
||||
let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController
|
||||
let page: TimelinesPageViewController.Page
|
||||
switch timeline {
|
||||
case .home:
|
||||
index = 0
|
||||
case .public(false):
|
||||
index = 1
|
||||
case .public(true):
|
||||
index = 2
|
||||
page = .home
|
||||
case .public(local: false):
|
||||
page = .federated
|
||||
case .public(local: true):
|
||||
page = .local
|
||||
default:
|
||||
fatalError()
|
||||
}
|
||||
rootController.segmentedControl.selectedSegmentIndex = index
|
||||
rootController.selectPage(at: index, animated: false)
|
||||
rootController.selectPage(page, animated: false)
|
||||
default:
|
||||
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
||||
navigationController.pushViewController(timeline, animated: false)
|
||||
|
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
|||
import Combine
|
||||
|
||||
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider {
|
||||
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int)
|
||||
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: ProfileViewController.Page)
|
||||
}
|
||||
|
||||
class ProfileHeaderView: UIView {
|
||||
|
@ -35,10 +35,11 @@ class ProfileHeaderView: UIView {
|
|||
@IBOutlet weak var displayNameLabel: EmojiLabel!
|
||||
@IBOutlet weak var usernameLabel: UILabel!
|
||||
@IBOutlet weak var lockImageView: UIImageView!
|
||||
@IBOutlet weak var vStack: UIStackView!
|
||||
@IBOutlet weak var relationshipLabel: UILabel!
|
||||
@IBOutlet weak var noteTextView: StatusContentTextView!
|
||||
@IBOutlet weak var fieldsView: ProfileFieldsView!
|
||||
@IBOutlet weak var pagesSegmentedControl: UISegmentedControl!
|
||||
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
|
||||
|
||||
var accountID: String!
|
||||
|
||||
|
@ -83,6 +84,22 @@ class ProfileHeaderView: UIView {
|
|||
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
|
||||
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,
|
||||
// so make it clear that to switch tabs the user needs to enter the group
|
||||
pagesSegmentedControl.accessibilityHint = "Enter group to select scope"
|
||||
|
@ -264,11 +281,6 @@ class ProfileHeaderView: UIView {
|
|||
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 {
|
||||
|
|
|
@ -69,42 +69,22 @@
|
|||
<nil key="highlightedColor"/>
|
||||
</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">
|
||||
<rect key="frame" x="0.0" y="0.0" width="382" height="259.5"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="382" height="460"/>
|
||||
<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"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="267.5" width="398" height="128"/>
|
||||
<rect key="frame" x="0.0" y="468" width="398" height="128"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
|
||||
</constraints>
|
||||
</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>
|
||||
<constraints>
|
||||
<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"/>
|
||||
</constraints>
|
||||
</stackView>
|
||||
|
@ -124,6 +104,13 @@
|
|||
</imageView>
|
||||
</subviews>
|
||||
</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>
|
||||
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
|
@ -133,9 +120,11 @@
|
|||
<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="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="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 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="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"/>
|
||||
|
@ -143,6 +132,7 @@
|
|||
<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="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 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"/>
|
||||
|
@ -157,9 +147,9 @@
|
|||
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
|
||||
<outlet property="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/>
|
||||
<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="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/>
|
||||
<outlet property="vStack" destination="u4P-3i-gEq" id="EUC-d2-cQC"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="-590" y="117"/>
|
||||
</view>
|
||||
|
|
|
@ -0,0 +1,228 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue