From 2389e1b25c67d96a2a7210ba1022b8b0b4dc4bac Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Thu, 21 Jan 2021 00:45:09 -0800 Subject: [PATCH] Re-write stuff in UIKit --- Extensions/Timeline+Extensions.swift | 40 +++ Localizations/Localizable.strings | 3 - Metatext.xcodeproj/project.pbxproj | 20 +- .../MainNavigationViewController.swift | 117 ++++++-- .../NewStatusViewController.swift | 18 +- .../TimelinesViewController.swift | 70 +++-- .../ViewModels/NavigationViewModel.swift | 93 ++---- Views/ListsView.swift | 9 +- Views/NewStatusButtonView.swift | 83 ++++++ Views/SecondaryNavigationButton.swift | 69 +++++ Views/SecondaryNavigationView.swift | 109 +++---- Views/TabNavigationView.swift | 268 ------------------ Views/TableView.swift | 10 - Views/TimelinesTitleView.swift | 153 ---------- Views/ViewConstants.swift | 2 +- 15 files changed, 424 insertions(+), 640 deletions(-) create mode 100644 Extensions/Timeline+Extensions.swift create mode 100644 Views/NewStatusButtonView.swift create mode 100644 Views/SecondaryNavigationButton.swift delete mode 100644 Views/TabNavigationView.swift delete mode 100644 Views/TimelinesTitleView.swift diff --git a/Extensions/Timeline+Extensions.swift b/Extensions/Timeline+Extensions.swift new file mode 100644 index 0000000..25d625b --- /dev/null +++ b/Extensions/Timeline+Extensions.swift @@ -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" + } + } +} diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 2c681fa..a52f82f 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -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 %@"; diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 3115bd6..b782d6e 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -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 = ""; }; D035F87C25B7F61600DC75ED /* TimelinesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesViewController.swift; sourceTree = ""; }; D035F88625B8016000DC75ED /* NavigationViewModel+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NavigationViewModel+Extensions.swift"; sourceTree = ""; }; - D035F89025B8067100DC75ED /* TimelinesTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesTitleView.swift; sourceTree = ""; }; + D035F8A825B9155900DC75ED /* NewStatusButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusButtonView.swift; sourceTree = ""; }; + D035F8B225B9616000DC75ED /* Timeline+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timeline+Extensions.swift"; sourceTree = ""; }; + D035F8C625B96A4000DC75ED /* SecondaryNavigationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryNavigationButton.swift; sourceTree = ""; }; D036AA01254B6101009094DF /* NotificationListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationListCell.swift; sourceTree = ""; }; D036AA06254B6118009094DF /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = ""; }; D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentConfiguration.swift; sourceTree = ""; }; @@ -290,7 +293,6 @@ D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostingReadingPreferencesView.swift; sourceTree = ""; }; D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecondaryNavigationView.swift; sourceTree = ""; }; D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationTypesPreferencesView.swift; sourceTree = ""; }; - D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabNavigationView.swift; sourceTree = ""; }; D0C7D45224F76169001EBDBB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D0C7D45424F76169001EBDBB /* MetatextApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MetatextApp.swift; sourceTree = ""; }; D0C7D45524F76169001EBDBB /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -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 = ""; @@ -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 */, diff --git a/View Controllers/MainNavigationViewController.swift b/View Controllers/MainNavigationViewController.swift index 5f79fcb..6d3c706 100644 --- a/View Controllers/MainNavigationViewController.swift +++ b/View Controllers/MainNavigationViewController.swift @@ -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() + 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) } } } diff --git a/View Controllers/NewStatusViewController.swift b/View Controllers/NewStatusViewController.swift index 8e2461a..9aba16e 100644 --- a/View Controllers/NewStatusViewController.swift +++ b/View Controllers/NewStatusViewController.swift @@ -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) { diff --git a/View Controllers/TimelinesViewController.swift b/View Controllers/TimelinesViewController.swift index cdd1d6b..452ecc7 100644 --- a/View Controllers/TimelinesViewController.swift +++ b/View Controllers/TimelinesViewController.swift @@ -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 } } diff --git a/ViewModels/Sources/ViewModels/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/NavigationViewModel.swift index 237127a..17fa39b 100644 --- a/ViewModels/Sources/ViewModels/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/NavigationViewModel.swift @@ -7,41 +7,11 @@ import ServiceLayer public final class NavigationViewModel: ObservableObject { public let identification: Identification + public let timelineNavigations: AnyPublisher + @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() private var cancellables = Set() 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 } -} diff --git a/Views/ListsView.swift b/Views/ListsView.swift index 29ab0fd..d366583 100644 --- a/Views/ListsView.swift +++ b/Views/ListsView.swift @@ -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 { diff --git a/Views/NewStatusButtonView.swift b/Views/NewStatusButtonView.swift new file mode 100644 index 0000000..426f64c --- /dev/null +++ b/Views/NewStatusButtonView.swift @@ -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) + ]) + } +} diff --git a/Views/SecondaryNavigationButton.swift b/Views/SecondaryNavigationButton.swift new file mode 100644 index 0000000..954cee3 --- /dev/null +++ b/Views/SecondaryNavigationButton.swift @@ -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() + + 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") + } +} diff --git a/Views/SecondaryNavigationView.swift b/Views/SecondaryNavigationView.swift index 1984363..72e0888 100644 --- a/Views/SecondaryNavigationView.swift +++ b/Views/SecondaryNavigationView.swift @@ -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) } } diff --git a/Views/TabNavigationView.swift b/Views/TabNavigationView.swift deleted file mode 100644 index 876a7af..0000000 --- a/Views/TabNavigationView.swift +++ /dev/null @@ -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 diff --git a/Views/TableView.swift b/Views/TableView.swift index 12653b2..6f3c9fa 100644 --- a/Views/TableView.swift +++ b/Views/TableView.swift @@ -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 diff --git a/Views/TimelinesTitleView.swift b/Views/TimelinesTitleView.swift deleted file mode 100644 index 5ca4185..0000000 --- a/Views/TimelinesTitleView.swift +++ /dev/null @@ -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) - } -} diff --git a/Views/ViewConstants.swift b/Views/ViewConstants.swift index 2aa90e5..5169229 100644 --- a/Views/ViewConstants.swift +++ b/Views/ViewConstants.swift @@ -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