Re-write stuff in UIKit

This commit is contained in:
Justin Mazzocchi 2021-01-21 00:45:09 -08:00
parent 02747215c5
commit 2389e1b25c
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
15 changed files with 424 additions and 640 deletions

View File

@ -0,0 +1,40 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Foundation
import ViewModels
extension Timeline {
var title: String {
switch self {
case .home:
return NSLocalizedString("timelines.home", comment: "")
case .local:
return NSLocalizedString("timelines.local", comment: "")
case .federated:
return NSLocalizedString("timelines.federated", comment: "")
case let .list(list):
return list.title
case let .tag(tag):
return "#".appending(tag)
case .profile:
return ""
case .favorites:
return NSLocalizedString("favorites", comment: "")
case .bookmarks:
return NSLocalizedString("bookmarks", comment: "")
}
}
var systemImageName: String {
switch self {
case .home: return "house"
case .local: return "building.2.crop.circle"
case .federated: return "network"
case .list: return "scroll"
case .tag: return "number"
case .profile: return "person"
case .favorites: return "star"
case .bookmarks: return "bookmark"
}
}
}

View File

@ -205,8 +205,5 @@
"status.visibility.direct.description" = "Visible for mentioned users only";
"submit" = "Submit";
"timelines.home" = "Home";
"timelines.home.description" = "Posts from accounts you're following";
"timelines.local" = "Local";
"timelines.local.description-%@" = "Public posts on %@";
"timelines.federated" = "Federated";
"timelines.federated.description-%@" = "Public posts on instances known by %@";

View File

@ -27,7 +27,9 @@
D035F86F25B7F30E00DC75ED /* MainNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F86E25B7F30E00DC75ED /* MainNavigationView.swift */; };
D035F87D25B7F61600DC75ED /* TimelinesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F87C25B7F61600DC75ED /* TimelinesViewController.swift */; };
D035F88725B8016000DC75ED /* NavigationViewModel+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F88625B8016000DC75ED /* NavigationViewModel+Extensions.swift */; };
D035F89125B8067100DC75ED /* TimelinesTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F89025B8067100DC75ED /* TimelinesTitleView.swift */; };
D035F8A925B9155900DC75ED /* NewStatusButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F8A825B9155900DC75ED /* NewStatusButtonView.swift */; };
D035F8B325B9616000DC75ED /* Timeline+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F8B225B9616000DC75ED /* Timeline+Extensions.swift */; };
D035F8C725B96A4000DC75ED /* SecondaryNavigationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F8C625B96A4000DC75ED /* SecondaryNavigationButton.swift */; };
D036AA02254B6101009094DF /* NotificationListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA01254B6101009094DF /* NotificationListCell.swift */; };
D036AA07254B6118009094DF /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA06254B6118009094DF /* NotificationView.swift */; };
D036AA0C254B612B009094DF /* NotificationContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */; };
@ -117,7 +119,6 @@
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */; };
D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */; };
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */; };
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */; };
D0C7D4C224F7616A001EBDBB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0C7D45224F76169001EBDBB /* Assets.xcassets */; };
D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D45424F76169001EBDBB /* MetatextApp.swift */; };
D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D45524F76169001EBDBB /* AppDelegate.swift */; };
@ -213,7 +214,9 @@
D035F86E25B7F30E00DC75ED /* MainNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationView.swift; sourceTree = "<group>"; };
D035F87C25B7F61600DC75ED /* TimelinesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesViewController.swift; sourceTree = "<group>"; };
D035F88625B8016000DC75ED /* NavigationViewModel+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NavigationViewModel+Extensions.swift"; sourceTree = "<group>"; };
D035F89025B8067100DC75ED /* TimelinesTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesTitleView.swift; sourceTree = "<group>"; };
D035F8A825B9155900DC75ED /* NewStatusButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusButtonView.swift; sourceTree = "<group>"; };
D035F8B225B9616000DC75ED /* Timeline+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timeline+Extensions.swift"; sourceTree = "<group>"; };
D035F8C625B96A4000DC75ED /* SecondaryNavigationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryNavigationButton.swift; sourceTree = "<group>"; };
D036AA01254B6101009094DF /* NotificationListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationListCell.swift; sourceTree = "<group>"; };
D036AA06254B6118009094DF /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = "<group>"; };
D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentConfiguration.swift; sourceTree = "<group>"; };
@ -290,7 +293,6 @@
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostingReadingPreferencesView.swift; sourceTree = "<group>"; };
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecondaryNavigationView.swift; sourceTree = "<group>"; };
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationTypesPreferencesView.swift; sourceTree = "<group>"; };
D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabNavigationView.swift; sourceTree = "<group>"; };
D0C7D45224F76169001EBDBB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D0C7D45424F76169001EBDBB /* MetatextApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MetatextApp.swift; sourceTree = "<group>"; };
D0C7D45524F76169001EBDBB /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@ -517,6 +519,7 @@
D035F86E25B7F30E00DC75ED /* MainNavigationView.swift */,
D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */,
D03B1B29253818F3008F964B /* MediaPreferencesView.swift */,
D035F8A825B9155900DC75ED /* NewStatusButtonView.swift */,
D0FCC10F259C4F20000B67DF /* NewStatusView.swift */,
D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */,
D036AA01254B6101009094DF /* NotificationListCell.swift */,
@ -532,12 +535,11 @@
D0DD50CA256B1F24004A04F7 /* ReportView.swift */,
D0C7D42724F76169001EBDBB /* RootView.swift */,
D02E1F94250B13210071AD56 /* SafariView.swift */,
D035F8C625B96A4000DC75ED /* SecondaryNavigationButton.swift */,
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */,
D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */,
D0625E55250F086B00502611 /* Status */,
D0C7D42524F76169001EBDBB /* TableView.swift */,
D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */,
D035F89025B8067100DC75ED /* TimelinesTitleView.swift */,
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */,
D0EA59472522B8B600804347 /* ViewConstants.swift */,
D0F2D54A2581CF7D00986197 /* VisualEffectBlur.swift */,
@ -601,6 +603,7 @@
D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */,
D0030981250C6C8500EACB32 /* URL+Extensions.swift */,
D0C7D46F24F76169001EBDBB /* View+Extensions.swift */,
D035F8B225B9616000DC75ED /* Timeline+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -825,7 +828,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */,
D07EC7CF25B13921006DF726 /* PickerEmoji+Extensions.swift in Sources */,
D00702292555E51200F38136 /* ConversationListCell.swift in Sources */,
@ -849,6 +851,7 @@
D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */,
D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */,
D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */,
D035F8B325B9616000DC75ED /* Timeline+Extensions.swift in Sources */,
D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */,
D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */,
D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */,
@ -884,18 +887,19 @@
D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */,
D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */,
D08B8D72254246E200B1EBEF /* PollView.swift in Sources */,
D035F8A925B9155900DC75ED /* NewStatusButtonView.swift in Sources */,
D0EA59402522AC8700804347 /* CardView.swift in Sources */,
D0F0B10E251A868200942152 /* AccountView.swift in Sources */,
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */,
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */,
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
D059373E25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */,
D035F89125B8067100DC75ED /* TimelinesTitleView.swift in Sources */,
D036AA17254CA824009094DF /* StatusBodyView.swift in Sources */,
D035F86F25B7F30E00DC75ED /* MainNavigationView.swift in Sources */,
D08E512125786A6600FA2C5F /* UIButton+Extensions.swift in Sources */,
D05936F425AA66A600754FDF /* UIView+Extensions.swift in Sources */,
D05936E925AA3F3D00754FDF /* EditAttachmentView.swift in Sources */,
D035F8C725B96A4000DC75ED /* SecondaryNavigationButton.swift in Sources */,
D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */,
D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */,
D036AA0C254B612B009094DF /* NotificationContentConfiguration.swift in Sources */,

View File

@ -1,11 +1,14 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
import Combine
import SwiftUI
import ViewModels
final class MainNavigationViewController: UITabBarController {
private let viewModel: NavigationViewModel
private let rootViewModel: RootViewModel
private var cancellables = Set<AnyCancellable>()
private weak var presentedSecondaryNavigation: UINavigationController?
init(viewModel: NavigationViewModel, rootViewModel: RootViewModel) {
self.viewModel = viewModel
@ -22,13 +25,40 @@ final class MainNavigationViewController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
let timelinesViewController = TimelinesViewController(
viewModel: viewModel,
rootViewModel: rootViewModel)
let timelinesNavigationController = UINavigationController(rootViewController: timelinesViewController)
setupViewControllers()
if let notificationsViewModel = viewModel.notificationsViewModel,
let conversationsViewModel = viewModel.conversationsViewModel {
if viewModel.identification.identity.authenticated {
setupNewStatusButton()
}
viewModel.$presentingSecondaryNavigation.sink { [weak self] in
if $0 {
self?.presentSecondaryNavigation()
} else {
self?.dismissSecondaryNavigation()
}
}
.store(in: &cancellables)
viewModel.timelineNavigations
.sink { [weak self] _ in self?.selectedIndex = 0 }
.store(in: &cancellables)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.refreshIdentity()
}
}
private extension MainNavigationViewController {
func setupViewControllers() {
var controllers: [UIViewController] = [TimelinesViewController(
viewModel: viewModel,
rootViewModel: rootViewModel)]
if let notificationsViewModel = viewModel.notificationsViewModel {
let notificationsViewController = TableViewController(
viewModel: notificationsViewModel,
rootViewModel: rootViewModel,
@ -36,9 +66,10 @@ final class MainNavigationViewController: UITabBarController {
notificationsViewController.tabBarItem = NavigationViewModel.Tab.notifications.tabBarItem
let notificationsNavigationViewController = UINavigationController(
rootViewController: notificationsViewController)
controllers.append(notificationsViewController)
}
if let conversationsViewModel = viewModel.conversationsViewModel {
let conversationsViewController = TableViewController(
viewModel: conversationsViewModel,
rootViewModel: rootViewModel,
@ -47,18 +78,64 @@ final class MainNavigationViewController: UITabBarController {
conversationsViewController.tabBarItem = NavigationViewModel.Tab.messages.tabBarItem
conversationsViewController.navigationItem.title = NavigationViewModel.Tab.messages.title
let conversationsNavigationViewController = UINavigationController(
rootViewController: conversationsViewController)
controllers.append(conversationsViewController)
}
viewControllers = [
timelinesNavigationController,
notificationsNavigationViewController,
conversationsNavigationViewController
]
} else {
viewControllers = [
timelinesNavigationController
]
let secondaryNavigationButton = SecondaryNavigationButton(viewModel: viewModel, rootViewModel: rootViewModel)
for controller in controllers {
controller.navigationItem.leftBarButtonItem = secondaryNavigationButton
}
viewControllers = controllers.map(UINavigationController.init(rootViewController:))
}
func setupNewStatusButton() {
let newStatusButtonView = NewStatusButtonView(primaryAction: UIAction { [weak self] _ in
guard let self = self else { return }
let newStatusViewModel = self.rootViewModel.newStatusViewModel(
identification: self.viewModel.identification)
let newStatusViewController = NewStatusViewController(viewModel: newStatusViewModel)
let newStatusNavigationController = UINavigationController(rootViewController: newStatusViewController)
if UIDevice.current.userInterfaceIdiom == .phone {
newStatusNavigationController.modalPresentationStyle = .overFullScreen
}
self.present(newStatusNavigationController, animated: true)
})
view.addSubview(newStatusButtonView)
newStatusButtonView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
newStatusButtonView.widthAnchor.constraint(equalToConstant: .newStatusButtonDimension),
newStatusButtonView.heightAnchor.constraint(equalToConstant: .newStatusButtonDimension),
newStatusButtonView.trailingAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.trailingAnchor,
constant: -.defaultSpacing * 2),
newStatusButtonView.bottomAnchor.constraint(equalTo: tabBar.topAnchor, constant: -.defaultSpacing * 2)
])
}
func presentSecondaryNavigation() {
let secondaryNavigationView = SecondaryNavigationView(viewModel: viewModel)
.environmentObject(rootViewModel)
let hostingController = UIHostingController(rootView: secondaryNavigationView)
hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .close,
primaryAction: UIAction { [weak self] _ in self?.viewModel.presentingSecondaryNavigation = false })
let navigationController = UINavigationController(rootViewController: hostingController)
presentedSecondaryNavigation = navigationController
present(navigationController, animated: true)
}
func dismissSecondaryNavigation() {
if presentedViewController == presentedSecondaryNavigation {
dismiss(animated: true)
}
}
}

View File

@ -66,6 +66,8 @@ final class NewStatusViewController: UIViewController {
activityIndicatorView.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor)
])
setupBarButtonItems(identification: viewModel.identification)
postButton.primaryAction = UIAction(title: NSLocalizedString("post", comment: "")) { [weak self] _ in
self?.viewModel.post()
}
@ -84,12 +86,6 @@ final class NewStatusViewController: UIViewController {
setupViewModelBindings()
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
setupBarButtonItems(identification: viewModel.identification)
}
}
extension NewStatusViewController: PHPickerViewControllerDelegate {
@ -251,15 +247,15 @@ private extension NewStatusViewController {
}
func setupBarButtonItems(identification: Identification) {
let closeButton = UIBarButtonItem(
systemItem: .close,
let cancelButton = UIBarButtonItem(
systemItem: .cancel,
primaryAction: UIAction { [weak self] _ in self?.dismiss() })
parent?.navigationItem.leftBarButtonItem = closeButton
parent?.navigationItem.titleView = viewModel.canChangeIdentity
navigationItem.leftBarButtonItem = cancelButton
navigationItem.titleView = viewModel.canChangeIdentity
? changeIdentityButton(identification: identification)
: nil
parent?.navigationItem.rightBarButtonItem = postButton
navigationItem.rightBarButtonItem = postButton
}
func presentMediaPicker(compositionViewModel: CompositionViewModel) {

View File

@ -5,7 +5,7 @@ import UIKit
import ViewModels
final class TimelinesViewController: UIPageViewController {
private let titleView: TimelinesTitleView
private let segmentedControl = UISegmentedControl()
private let timelineViewControllers: [TableViewController]
private let viewModel: NavigationViewModel
private let rootViewModel: RootViewModel
@ -15,31 +15,18 @@ final class TimelinesViewController: UIPageViewController {
self.viewModel = viewModel
self.rootViewModel = rootViewModel
let timelineViewModels: [CollectionViewModel]
var timelineViewControllers = [TableViewController]()
if let homeTimelineViewModel = viewModel.homeTimelineViewModel {
timelineViewModels = [
homeTimelineViewModel,
viewModel.localTimelineViewModel,
viewModel.federatedTimelineViewModel]
} else {
timelineViewModels = [
viewModel.localTimelineViewModel,
viewModel.federatedTimelineViewModel]
for (index, timeline) in viewModel.timelines.enumerated() {
timelineViewControllers.append(
TableViewController(
viewModel: viewModel.viewModel(timeline: timeline),
rootViewModel: rootViewModel,
identification: viewModel.identification))
segmentedControl.insertSegment(withTitle: timeline.title, at: index, animated: false)
}
titleView = TimelinesTitleView(
timelines: viewModel.identification.identity.authenticated
? Timeline.authenticatedDefaults
: Timeline.unauthenticatedDefaults,
identification: viewModel.identification)
timelineViewControllers = timelineViewModels.map {
TableViewController(
viewModel: $0,
rootViewModel: rootViewModel,
identification: viewModel.identification)
}
self.timelineViewControllers = timelineViewControllers
super.init(transitionStyle: .scroll,
navigationOrientation: .horizontal,
@ -66,24 +53,35 @@ final class TimelinesViewController: UIPageViewController {
image: UIImage(systemName: "newspaper"),
selectedImage: nil)
navigationItem.titleView = titleView
navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .close)
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "megaphone"), primaryAction: nil)
titleView.$selectedTimeline
.compactMap { [weak self] in self?.titleView.timelines.firstIndex(of: $0) }
.sink { [weak self] index in
navigationItem.titleView = segmentedControl
segmentedControl.selectedSegmentIndex = 0
segmentedControl.addAction(
UIAction { [weak self] _ in
guard let self = self,
let currentViewController = self.viewControllers?.first as? TableViewController,
let currentIndex = self.timelineViewControllers.firstIndex(of: currentViewController),
index != currentIndex
self.segmentedControl.selectedSegmentIndex != currentIndex
else { return }
self.setViewControllers(
[self.timelineViewControllers[index]],
direction: index > currentIndex ? .forward : .reverse,
[self.timelineViewControllers[self.segmentedControl.selectedSegmentIndex]],
direction: self.segmentedControl.selectedSegmentIndex > currentIndex ? .forward : .reverse,
animated: !UIAccessibility.isReduceMotionEnabled)
},
for: .valueChanged)
viewModel.timelineNavigations.sink { [weak self] in
guard let self = self else { return }
let vc = TableViewController(
viewModel: self.viewModel.viewModel(timeline: $0),
rootViewModel: self.rootViewModel,
identification: self.viewModel.identification)
vc.navigationItem.title = $0.title
self.show(vc, sender: self)
}
.store(in: &cancellables)
}
@ -122,10 +120,6 @@ extension TimelinesViewController: UIPageViewControllerDelegate {
let index = timelineViewControllers.firstIndex(of: viewController)
else { return }
let timeline = titleView.timelines[index]
if titleView.selectedTimeline != timeline {
titleView.selectedTimeline = timeline
}
segmentedControl.selectedSegmentIndex = index
}
}

View File

@ -7,41 +7,11 @@ import ServiceLayer
public final class NavigationViewModel: ObservableObject {
public let identification: Identification
public let timelineNavigations: AnyPublisher<Timeline, Never>
@Published public private(set) var recentIdentities = [Identity]()
@Published public var timeline: Timeline {
didSet {
timelineViewModel = CollectionItemsViewModel(
collectionService: identification.service.service(timeline: timeline),
identification: identification)
}
}
@Published public private(set) var timelinesAndLists: [Timeline]
@Published public var presentingSecondaryNavigation = false
@Published public var presentingNewStatus = false
@Published public var alertItem: AlertItem?
public private(set) var timelineViewModel: CollectionItemsViewModel
public lazy var homeTimelineViewModel: CollectionViewModel? = {
if identification.identity.authenticated {
return CollectionItemsViewModel(
collectionService: identification.service.service(timeline: .home),
identification: identification)
}
return nil
}()
public lazy var localTimelineViewModel: CollectionViewModel = {
CollectionItemsViewModel(
collectionService: identification.service.service(timeline: .local),
identification: identification)
}()
public lazy var federatedTimelineViewModel: CollectionViewModel = {
CollectionItemsViewModel(
collectionService: identification.service.service(timeline: .federated),
identification: identification)
}()
public lazy var notificationsViewModel: CollectionViewModel? = {
if identification.identity.authenticated {
@ -71,18 +41,12 @@ public final class NavigationViewModel: ObservableObject {
}
}()
private let timelineNavigationsSubject = PassthroughSubject<Timeline, Never>()
private var cancellables = Set<AnyCancellable>()
public init(identification: Identification) {
self.identification = identification
let timeline: Timeline = identification.identity.authenticated ? .home : .local
self.timeline = timeline
timelineViewModel = CollectionItemsViewModel(
collectionService: identification.service.service(timeline: timeline),
identification: identification)
timelinesAndLists = identification.identity.authenticated
? Timeline.authenticatedDefaults
: Timeline.unauthenticatedDefaults
timelineNavigations = timelineNavigationsSubject.eraseToAnyPublisher()
identification.$identity
.sink { [weak self] _ in self?.objectWillChange.send() }
@ -91,17 +55,17 @@ public final class NavigationViewModel: ObservableObject {
identification.service.recentIdentitiesPublisher()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$recentIdentities)
if identification.identity.authenticated {
identification.service.listsPublisher()
.map { Timeline.authenticatedDefaults + $0 }
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$timelinesAndLists)
}
}
}
public extension NavigationViewModel {
enum Tab: CaseIterable {
case timelines
case explore
case notifications
case messages
}
var tabs: [Tab] {
if identification.identity.authenticated {
return Tab.allCases
@ -110,12 +74,11 @@ public extension NavigationViewModel {
}
}
var timelineSubtitle: String {
switch timeline {
case .home, .favorites, .bookmarks, .list:
return identification.identity.handle
case .local, .federated, .tag, .profile:
return identification.identity.instance?.uri ?? ""
var timelines: [Timeline] {
if identification.identity.authenticated {
return Timeline.authenticatedDefaults
} else {
return Timeline.unauthenticatedDefaults
}
}
@ -156,29 +119,15 @@ public extension NavigationViewModel {
.sink { _ in } receiveValue: { _ in }
.store(in: &cancellables)
}
}
public extension NavigationViewModel {
enum Tab: CaseIterable {
case timelines
case explore
case notifications
case messages
func navigate(timeline: Timeline) {
presentingSecondaryNavigation = false
timelineNavigationsSubject.send(timeline)
}
func favoritesViewModel() -> CollectionViewModel {
func viewModel(timeline: Timeline) -> CollectionItemsViewModel {
CollectionItemsViewModel(
collectionService: identification.service.service(timeline: .favorites),
identification: identification)
}
func bookmarksViewModel() -> CollectionViewModel {
CollectionItemsViewModel(
collectionService: identification.service.service(timeline: .bookmarks),
collectionService: identification.service.service(timeline: timeline),
identification: identification)
}
}
extension NavigationViewModel.Tab: Identifiable {
public var id: Self { self }
}

View File

@ -1,5 +1,6 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Mastodon
import SwiftUI
import ViewModels
@ -27,9 +28,11 @@ struct ListsView: View {
}
Section {
ForEach(viewModel.lists) { list in
Button(list.title) {
rootViewModel.navigationViewModel?.timeline = .list(list)
rootViewModel.navigationViewModel?.presentingSecondaryNavigation = false
Button {
rootViewModel.navigationViewModel?.navigate(timeline: .list(list))
} label: {
Text(list.title)
.foregroundColor(.primary)
}
}
.onDelete {

View File

@ -0,0 +1,83 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
final class NewStatusButtonView: UIView {
let button: UIButton
init(primaryAction: UIAction) {
button = UIButton(type: .custom, primaryAction: primaryAction)
super.init(frame: .zero)
initialSetup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension NewStatusButtonView {
// swiftlint:disable:next function_body_length
func initialSetup() {
let blurEffect = UIBlurEffect(style: .systemChromeMaterial)
let blurView = UIVisualEffectView(effect: blurEffect)
let vibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blurEffect, style: .label))
backgroundColor = .clear
layer.cornerRadius = .newStatusButtonDimension / 2
layer.shadowPath = UIBezierPath(
ovalIn: .init(
origin: .zero,
size: .init(
width: .newStatusButtonDimension,
height: .newStatusButtonDimension)))
.cgPath
layer.shadowOffset = .zero
layer.shadowRadius = .defaultShadowRadius
layer.shadowOpacity = 0.25
addSubview(blurView)
blurView.translatesAutoresizingMaskIntoConstraints = false
blurView.layer.cornerRadius = .newStatusButtonDimension / 2
blurView.clipsToBounds = true
blurView.contentView.addSubview(vibrancyView)
vibrancyView.translatesAutoresizingMaskIntoConstraints = false
let touchStartAction = UIAction { [weak self] _ in self?.alpha = 0.75 }
button.translatesAutoresizingMaskIntoConstraints = false
button.addAction(touchStartAction, for: .touchDown)
button.addAction(touchStartAction, for: .touchDragEnter)
let touchEndAction = UIAction { [weak self] _ in self?.alpha = 1 }
button.addAction(touchEndAction, for: .touchDragExit)
button.addAction(touchEndAction, for: .touchUpInside)
button.addAction(touchEndAction, for: .touchUpOutside)
button.addAction(touchEndAction, for: .touchCancel)
button.setImage(
UIImage(systemName: "pencil",
withConfiguration: UIImage.SymbolConfiguration(pointSize: .newStatusButtonDimension / 2)),
for: .normal)
vibrancyView.contentView.addSubview(button)
NSLayoutConstraint.activate([
blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
blurView.topAnchor.constraint(equalTo: topAnchor),
blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
vibrancyView.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor),
vibrancyView.topAnchor.constraint(equalTo: blurView.contentView.topAnchor),
vibrancyView.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor),
vibrancyView.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor),
button.leadingAnchor.constraint(equalTo: vibrancyView.contentView.leadingAnchor),
button.topAnchor.constraint(equalTo: vibrancyView.contentView.topAnchor),
button.trailingAnchor.constraint(equalTo: vibrancyView.contentView.trailingAnchor),
button.bottomAnchor.constraint(equalTo: vibrancyView.contentView.bottomAnchor)
])
}
}

View File

@ -0,0 +1,69 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Combine
import Kingfisher
import UIKit
import ViewModels
final class SecondaryNavigationButton: UIBarButtonItem {
private var cancellables = Set<AnyCancellable>()
init(viewModel: NavigationViewModel, rootViewModel: RootViewModel) {
super.init()
let button = UIButton(
type: .custom,
primaryAction: UIAction { _ in viewModel.presentingSecondaryNavigation = true })
let downsampled = KingfisherOptionsInfo.downsampled(
dimension: .barButtonItemDimension,
scaleFactor: UIScreen.main.scale)
button.imageView?.contentMode = .scaleAspectFill
button.layer.cornerRadius = .barButtonItemDimension / 2
button.clipsToBounds = true
customView = button
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: .barButtonItemDimension),
button.heightAnchor.constraint(equalToConstant: .barButtonItemDimension)
])
viewModel.identification.$identity.sink {
button.kf.setImage(
with: $0.image,
for: .normal,
placeholder: UIImage(systemName: "line.horizontal.3"),
options: downsampled)
}
.store(in: &cancellables)
viewModel.$recentIdentities.sink { identities in
button.menu = UIMenu(children: identities.map { identity in
UIDeferredMenuElement { completion in
let action = UIAction(title: identity.handle) { _ in
rootViewModel.identitySelected(id: identity.id)
}
if let image = identity.image {
KingfisherManager.shared.retrieveImage(with: image, options: downsampled) {
if case let .success(value) = $0 {
action.image = value.image
}
completion([action])
}
} else {
completion([action])
}
}
})
}
.store(in: &cancellables)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -10,73 +10,76 @@ struct SecondaryNavigationView: View {
@Environment(\.displayScale) var displayScale: CGFloat
var body: some View {
NavigationView {
Form {
Section {
NavigationLink(
destination: IdentitiesView(viewModel: .init(identification: viewModel.identification)),
label: {
HStack {
KFImage(viewModel.identification.identity.image)
.downsampled(dimension: .avatarDimension, scaleFactor: displayScale)
VStack(alignment: .leading) {
if viewModel.identification.identity.authenticated {
if let account = viewModel.identification.identity.account {
CustomEmojiText(
text: account.displayName,
emojis: account.emojis,
textStyle: .headline)
}
Text(viewModel.identification.identity.handle)
Form {
Section {
NavigationLink(
destination: IdentitiesView(viewModel: .init(identification: viewModel.identification))
.environmentObject(rootViewModel)
.environmentObject(viewModel.identification),
label: {
HStack {
KFImage(viewModel.identification.identity.image)
.downsampled(dimension: .avatarDimension, scaleFactor: displayScale)
VStack(alignment: .leading) {
if viewModel.identification.identity.authenticated {
if let account = viewModel.identification.identity.account {
CustomEmojiText(
text: account.displayName,
emojis: account.emojis,
textStyle: .headline)
}
Text(viewModel.identification.identity.handle)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.5)
} else {
Text(viewModel.identification.identity.handle)
.font(.headline)
if let instance = viewModel.identification.identity.instance {
Text(instance.uri)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.5)
} else {
Text(viewModel.identification.identity.handle)
.font(.headline)
if let instance = viewModel.identification.identity.instance {
Text(instance.uri)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.5)
}
}
Spacer()
Text("secondary-navigation.manage-accounts")
.font(.subheadline)
}
.padding()
Spacer()
Text("secondary-navigation.manage-accounts")
.font(.subheadline)
}
})
.padding()
}
})
}
Section {
NavigationLink(destination: ListsView(viewModel: .init(identification: viewModel.identification))
.environmentObject(rootViewModel)
.environmentObject(viewModel.identification)) {
Label("secondary-navigation.lists", systemImage: "scroll")
}
Section {
NavigationLink(destination: ListsView(viewModel: .init(identification: viewModel.identification))) {
Label("secondary-navigation.lists", systemImage: "scroll")
ForEach([Timeline.favorites, Timeline.bookmarks]) { timeline in
Button {
viewModel.navigate(timeline: timeline)
} label: {
Label {
Text(timeline.title).foregroundColor(.primary)
} icon: {
Image(systemName: timeline.systemImageName)
}
}
}
Section {
NavigationLink(
"secondary-navigation.preferences",
destination: PreferencesView(
viewModel: .init(identification: viewModel.identification)))
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
viewModel.presentingSecondaryNavigation = false
} label: {
Image(systemName: "xmark.circle.fill")
}
Section {
NavigationLink(
destination: PreferencesView(viewModel: .init(identification: viewModel.identification))
.environmentObject(rootViewModel)
.environmentObject(viewModel.identification)) {
Label("secondary-navigation.preferences", systemImage: "gear")
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
.environmentObject(viewModel.identification)
}
}

View File

@ -1,268 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Kingfisher
import SwiftUI
import ViewModels
struct TabNavigationView: View {
@ObservedObject var viewModel: NavigationViewModel
@EnvironmentObject var rootViewModel: RootViewModel
@Environment(\.displayScale) var displayScale: CGFloat
@State var selectedTab = NavigationViewModel.Tab.timelines
@State private var contextMenuImages = [UUID: KFImage]()
var body: some View {
Group {
if viewModel.identification.identity.pending {
pendingView
} else {
TabView(selection: $selectedTab) {
ForEach(viewModel.tabs) { tab in
NavigationView {
view(tab: tab)
}
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Label(tab.title, systemImage: tab.systemImageName)
.accessibility(label: Text(tab.title))
}
.tag(tab)
.overlay(newStatusButton, alignment: .bottomTrailing)
}
}
}
}
.environmentObject(viewModel.identification)
.sheet(isPresented: $viewModel.presentingSecondaryNavigation) {
SecondaryNavigationView(viewModel: viewModel)
.environmentObject(viewModel)
.environmentObject(rootViewModel)
}
.background(
EmptyView()
.fullScreenCover(isPresented: $viewModel.presentingNewStatus) {
NavigationView {
NewStatusView { rootViewModel.newStatusViewModel(identification: viewModel.identification) }
.navigationBarTitleDisplayMode(.inline)
}
.navigationViewStyle(StackNavigationViewStyle())
.environmentObject(viewModel)
.environmentObject(rootViewModel)
})
.alertItem($viewModel.alertItem)
.onAppear(perform: viewModel.refreshIdentity)
// Have to preload these, otherwise the context menu won't display them when first expanded
.onReceive(viewModel.$recentIdentities) {
contextMenuImages = Dictionary(uniqueKeysWithValues: $0.map {
($0.id, KFImage($0.image)
.downsampled(
dimension: .barButtonItemDimension,
scaleFactor: displayScale)
.renderingMode(.original))
})
}
.onReceive(NotificationCenter.default
.publisher(for: UIScene.willEnterForegroundNotification)
.map { _ in () },
perform: viewModel.refreshIdentity)
}
}
private extension TabNavigationView {
@ViewBuilder
var pendingView: some View {
NavigationView {
Text("pending.pending-confirmation")
.navigationBarItems(leading: secondaryNavigationButton)
.navigationTitle(viewModel.identification.identity.handle)
.navigationBarTitleDisplayMode(.inline)
}
.navigationViewStyle(StackNavigationViewStyle())
}
@ViewBuilder
// swiftlint:disable:next function_body_length
func view(tab: NavigationViewModel.Tab) -> some View {
switch tab {
case .timelines:
TableView { viewModel.timelineViewModel }
.id(viewModel.timeline.id)
.edgesIgnoringSafeArea(.all)
.navigationTitle(viewModel.timeline.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
VStack {
Text(viewModel.timeline.title)
.font(.headline)
Text(viewModel.timelineSubtitle)
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
.navigationBarItems(
leading: secondaryNavigationButton,
trailing: Menu {
ForEach(viewModel.timelinesAndLists) { timeline in
Button {
viewModel.timeline = timeline
} label: {
Label(timeline.title,
systemImage: timeline.systemImageName)
}
}
} label: {
Image(systemName: viewModel.timeline.systemImageName)
.padding([.leading, .top, .bottom])
})
case .notifications:
if let notificationsViewModel = viewModel.notificationsViewModel {
TableView { notificationsViewModel }
.id(tab)
.edgesIgnoringSafeArea(.all)
.navigationTitle("notifications")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: secondaryNavigationButton)
}
case .messages:
if let conversationsViewModel = viewModel.conversationsViewModel {
TableView { conversationsViewModel }
.id(tab)
.edgesIgnoringSafeArea(.all)
.navigationTitle("messages")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: secondaryNavigationButton)
}
default: Text(tab.title)
}
}
@ViewBuilder
var secondaryNavigationButton: some View {
Button {
viewModel.presentingSecondaryNavigation.toggle()
} label: {
KFImage(viewModel.identification.identity.image)
.downsampled(
dimension: .barButtonItemDimension,
scaleFactor: displayScale)
.placeholder { Image(systemName: "gear") }
.renderingMode(.original)
.contextMenu(ContextMenu {
ForEach(viewModel.recentIdentities) { recentIdentity in
Button {
rootViewModel.identitySelected(id: recentIdentity.id)
} label: {
Label(
title: { Text(recentIdentity.handle) },
icon: { contextMenuImages[recentIdentity.id] })
}
}
})
.padding([.trailing, .top, .bottom])
}
}
@ViewBuilder
var newStatusButton: some View {
if viewModel.identification.identity.authenticated
&& !viewModel.identification.identity.pending {
Button {
viewModel.presentingNewStatus = true
} label: {
VisualEffectBlur(vibrancyStyle: .label) {
Image(systemName: "pencil")
.resizable()
.frame(width: .newStatusButtonDimension / 2,
height: .newStatusButtonDimension / 2)
}
.clipShape(Circle())
.frame(width: .newStatusButtonDimension,
height: .newStatusButtonDimension)
.shadow(radius: .defaultShadowRadius)
.padding()
}
}
}
}
// TODO: move
extension Timeline {
var title: String {
switch self {
case .home:
return NSLocalizedString("timelines.home", comment: "")
case .local:
return NSLocalizedString("timelines.local", comment: "")
case .federated:
return NSLocalizedString("timelines.federated", comment: "")
case let .list(list):
return list.title
case let .tag(tag):
return "#".appending(tag)
case .profile:
return ""
case .favorites:
return NSLocalizedString("favorites", comment: "")
case .bookmarks:
return NSLocalizedString("bookmarks", comment: "")
}
}
func subtitle(identification: Identification) -> String? {
switch self {
case .home:
return identification.identity.handle
default:
return identification.identity.instance?.uri
}
}
func description(instanceName: String?) -> String? {
switch self {
case .home:
return NSLocalizedString("timelines.home.description", comment: "")
case .local:
guard let instanceName = instanceName else { return nil }
return String.localizedStringWithFormat(
NSLocalizedString("timelines.local.description-%@", comment: ""),
instanceName)
case .federated:
guard let instanceName = instanceName else { return nil }
return String.localizedStringWithFormat(
NSLocalizedString("timelines.federated.description-%@", comment: ""),
instanceName)
default:
return nil
}
}
var systemImageName: String {
switch self {
case .home: return "house"
case .local: return "building.2.crop.circle"
case .federated: return "network"
case .list: return "scroll"
case .tag: return "number"
case .profile: return "person"
case .favorites: return "star"
case .bookmarks: return "bookmark"
}
}
}
#if DEBUG
import PreviewViewModels
struct TabNavigation_Previews: PreviewProvider {
static var previews: some View {
TabNavigationView(viewModel: NavigationViewModel(identification: .preview))
.environmentObject(Identification.preview)
.environmentObject(RootViewModel.preview)
}
}
#endif

View File

@ -16,13 +16,3 @@ struct TableView: UIViewControllerRepresentable {
}
}
#if DEBUG
import PreviewViewModels
struct StatusListView_Previews: PreviewProvider {
static var previews: some View {
TableView { NavigationViewModel(identification: .preview).timelineViewModel }
}
}
#endif

View File

@ -1,153 +0,0 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Combine
import UIKit
import ViewModels
final class TimelinesTitleView: UIControl {
let timelines: [Timeline]
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let imageView = UIImageView()
private let chevronImageView = UIImageView(image: TimelinesTitleView.closedImage)
private let identification: Identification
@Published var selectedTimeline: Timeline {
didSet { applyTimelineSelection() }
}
init(timelines: [Timeline], identification: Identification) {
self.timelines = timelines
self.identification = identification
guard let timeline = timelines.first else {
fatalError("TimelinesTitleView must be initialized with a non-empty timelines array")
}
selectedTimeline = timeline
super.init(frame: .zero)
accessibilityTraits = .button
isAccessibilityElement = true
showsMenuAsPrimaryAction = true
isContextMenuInteractionEnabled = true
addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.setContentHuggingPriority(.required, for: .horizontal)
imageView.tintColor = .label
addSubview(chevronImageView)
chevronImageView.translatesAutoresizingMaskIntoConstraints = false
chevronImageView.contentMode = .scaleAspectFit
chevronImageView.setContentHuggingPriority(.required, for: .horizontal)
addSubview(titleLabel)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.adjustsFontSizeToFitWidth = true
titleLabel.minimumScaleFactor = 0.5
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
titleLabel.setContentHuggingPriority(.required, for: .vertical)
titleLabel.setContentCompressionResistancePriority(.required, for: .vertical)
addSubview(subtitleLabel)
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
subtitleLabel.adjustsFontForContentSizeCategory = true
subtitleLabel.font = .preferredFont(forTextStyle: .caption2)
subtitleLabel.adjustsFontSizeToFitWidth = true
subtitleLabel.textAlignment = .center
subtitleLabel.minimumScaleFactor = 0.5
subtitleLabel.textColor = .secondaryLabel
subtitleLabel.setContentHuggingPriority(.required, for: .vertical)
subtitleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
subtitleLabel.setContentCompressionResistancePriority(.justBelowMax, for: .vertical)
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor),
imageView.topAnchor.constraint(equalTo: titleLabel.topAnchor),
imageView.bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor),
titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
titleLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: .compactSpacing),
titleLabel.topAnchor.constraint(equalTo: topAnchor),
chevronImageView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: .defaultSpacing),
chevronImageView.topAnchor.constraint(equalTo: titleLabel.topAnchor),
chevronImageView.bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor),
chevronImageView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor),
subtitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor)
])
applyTimelineSelection()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var isHighlighted: Bool {
didSet {
alpha = isHighlighted ? Self.highlightedAlpha : 1
}
}
override func menuAttachmentPoint(for configuration: UIContextMenuConfiguration) -> CGPoint {
CGPoint(x: (bounds.width - .systemMenuWidth) / 2 + .systemMenuInset, y: bounds.maxY + .compactSpacing)
}
override func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
guard let self = self else { return nil }
return UIMenu(children: self.timelines.map { timeline in
UIAction(
title: timeline.title,
image: UIImage(systemName: timeline.systemImageName),
attributes: timeline == self.selectedTimeline ? .disabled : [],
state: timeline == self.selectedTimeline ? .on : .off) { _ in
self.selectedTimeline = timeline
}
})
}
}
override func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
willDisplayMenuFor configuration: UIContextMenuConfiguration,
animator: UIContextMenuInteractionAnimating?) {
chevronImageView.image = Self.openImage
}
override func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
willEndFor configuration: UIContextMenuConfiguration,
animator: UIContextMenuInteractionAnimating?) {
chevronImageView.image = Self.closedImage
alpha = 1 // system bug
}
}
private extension TimelinesTitleView {
static let highlightedAlpha: CGFloat = 0.5
static let openImage = UIImage(
systemName: "chevron.compact.up",
withConfiguration: UIImage.SymbolConfiguration(scale: .small))
static let closedImage = UIImage(
systemName: "chevron.compact.down",
withConfiguration: UIImage.SymbolConfiguration(scale: .small))
func applyTimelineSelection() {
imageView.image = UIImage(
systemName: selectedTimeline.systemImageName,
withConfiguration: UIImage.SymbolConfiguration(scale: .small))
titleLabel.text = selectedTimeline.title
subtitleLabel.text = selectedTimeline.subtitle(identification: identification)
}
}

View File

@ -11,7 +11,7 @@ extension CGFloat {
static let hairline = 1 / UIScreen.main.scale
static let minimumButtonDimension: Self = 44
static let barButtonItemDimension: Self = 28
static let newStatusButtonDimension: Self = 54
static let newStatusButtonDimension: Self = 58
static let defaultShadowRadius: Self = 2
static let systemMenuWidth: Self = 250
static let systemMenuInset: Self = 15