From 02747215c5b26ae4144f9350f1beaa2bb103558f Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Wed, 20 Jan 2021 15:33:53 -0800 Subject: [PATCH] wip --- DB/Sources/DB/Entities/Timeline.swift | 2 +- .../NavigationViewModel+Extensions.swift | 33 ++++ Localizations/Localizable.strings | 7 + Metatext.xcodeproj/project.pbxproj | 20 +++ .../MainNavigationViewController.swift | 64 ++++++++ .../TimelinesViewController.swift | 131 +++++++++++++++ .../ViewModels/NavigationViewModel.swift | 50 ++++-- Views/MainNavigationView.swift | 32 ++++ Views/RootView.swift | 3 +- Views/TabNavigationView.swift | 55 ++++--- Views/TimelinesTitleView.swift | 153 ++++++++++++++++++ Views/ViewConstants.swift | 6 +- 12 files changed, 514 insertions(+), 42 deletions(-) create mode 100644 Extensions/NavigationViewModel+Extensions.swift create mode 100644 View Controllers/MainNavigationViewController.swift create mode 100644 View Controllers/TimelinesViewController.swift create mode 100644 Views/MainNavigationView.swift create mode 100644 Views/TimelinesTitleView.swift diff --git a/DB/Sources/DB/Entities/Timeline.swift b/DB/Sources/DB/Entities/Timeline.swift index 3f9ca30..60e54cd 100644 --- a/DB/Sources/DB/Entities/Timeline.swift +++ b/DB/Sources/DB/Entities/Timeline.swift @@ -18,7 +18,7 @@ public extension Timeline { typealias Id = String static let unauthenticatedDefaults: [Timeline] = [.local, .federated] - static let authenticatedDefaults: [Timeline] = [.home, .local, .federated, .favorites, .bookmarks] + static let authenticatedDefaults: [Timeline] = [.home, .local, .federated] var filterContext: Filter.Context? { switch self { diff --git a/Extensions/NavigationViewModel+Extensions.swift b/Extensions/NavigationViewModel+Extensions.swift new file mode 100644 index 0000000..a7ca3b2 --- /dev/null +++ b/Extensions/NavigationViewModel+Extensions.swift @@ -0,0 +1,33 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Foundation +import UIKit +import ViewModels + +extension NavigationViewModel.Tab { + var title: String { + switch self { + case .timelines: + return NSLocalizedString("main-navigation.timelines", comment: "") + case .explore: + return NSLocalizedString("main-navigation.explore", comment: "") + case .notifications: + return NSLocalizedString("main-navigation.notifications", comment: "") + case .messages: + return NSLocalizedString("main-navigation.conversations", comment: "") + } + } + + var systemImageName: String { + switch self { + case .timelines: return "newspaper" + case .explore: return "magnifyingglass" + case .notifications: return "bell" + case .messages: return "envelope" + } + } + + var tabBarItem: UITabBarItem { + UITabBarItem(title: title, image: UIImage(systemName: systemImageName), selectedImage: nil) + } +} diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 0825583..2c681fa 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -88,6 +88,10 @@ "identities.pending" = "Pending"; "lists.new-list-title" = "New List Title"; "load-more" = "Load More"; +"main-navigation.timelines" = "Timelines"; +"main-navigation.explore" = "Explore"; +"main-navigation.notifications" = "Notifications"; +"main-navigation.conversations" = "Conversations"; "messages" = "Messages"; "ok" = "OK"; "pending.pending-confirmation" = "Your account is pending confirmation"; @@ -201,5 +205,8 @@ "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 cb8ab2f..3115bd6 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -23,6 +23,11 @@ D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; }; D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* AttachmentsView.swift */; }; D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; }; + D035F86925B7F2ED00DC75ED /* MainNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D035F86825B7F2ED00DC75ED /* MainNavigationViewController.swift */; }; + 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 */; }; 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 */; }; @@ -204,6 +209,11 @@ D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = ""; }; D01F41E224F8889700D55A2D /* AttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentsView.swift; sourceTree = ""; }; D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; + D035F86825B7F2ED00DC75ED /* MainNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationViewController.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; @@ -504,6 +514,7 @@ D0B8510B25259E56004E0744 /* LoadMoreCell.swift */, D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */, D0E569DA2529319100FA1D72 /* LoadMoreView.swift */, + D035F86E25B7F30E00DC75ED /* MainNavigationView.swift */, D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */, D03B1B29253818F3008F964B /* MediaPreferencesView.swift */, D0FCC10F259C4F20000B67DF /* NewStatusView.swift */, @@ -526,6 +537,7 @@ D0625E55250F086B00502611 /* Status */, D0C7D42524F76169001EBDBB /* TableView.swift */, D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */, + D035F89025B8067100DC75ED /* TimelinesTitleView.swift */, D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */, D0EA59472522B8B600804347 /* ViewConstants.swift */, D0F2D54A2581CF7D00986197 /* VisualEffectBlur.swift */, @@ -542,9 +554,11 @@ D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */, D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */, D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */, + D035F86825B7F2ED00DC75ED /* MainNavigationViewController.swift */, D0FCC104259C4E61000B67DF /* NewStatusViewController.swift */, D06BC5E525202AD90079541D /* ProfileViewController.swift */, D0F0B12D251A97E400942152 /* TableViewController.swift */, + D035F87C25B7F61600DC75ED /* TimelinesViewController.swift */, ); path = "View Controllers"; sourceTree = ""; @@ -574,6 +588,7 @@ D05E688425B55AE8001FB2C6 /* AVURLAsset+Extensions.swift */, D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */, D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */, + D035F88625B8016000DC75ED /* NavigationViewModel+Extensions.swift */, D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */, D07EC7CE25B13921006DF726 /* PickerEmoji+Extensions.swift */, D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */, @@ -856,6 +871,7 @@ D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */, D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */, D07EC81125B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */, + D035F87D25B7F61600DC75ED /* TimelinesViewController.swift in Sources */, D059373325AAEA7000754FDF /* CompositionPollView.swift in Sources */, D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */, D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */, @@ -874,7 +890,9 @@ 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 */, @@ -883,6 +901,7 @@ D036AA0C254B612B009094DF /* NotificationContentConfiguration.swift in Sources */, D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */, D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */, + D035F86925B7F2ED00DC75ED /* MainNavigationViewController.swift in Sources */, D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */, D08E52612579D2E100FA2C5F /* DomainBlocksView.swift in Sources */, D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */, @@ -901,6 +920,7 @@ D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */, D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */, D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */, + D035F88725B8016000DC75ED /* NavigationViewModel+Extensions.swift in Sources */, D0FCC110259C4F20000B67DF /* NewStatusView.swift in Sources */, D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */, D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */, diff --git a/View Controllers/MainNavigationViewController.swift b/View Controllers/MainNavigationViewController.swift new file mode 100644 index 0000000..5f79fcb --- /dev/null +++ b/View Controllers/MainNavigationViewController.swift @@ -0,0 +1,64 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +final class MainNavigationViewController: UITabBarController { + private let viewModel: NavigationViewModel + private let rootViewModel: RootViewModel + + init(viewModel: NavigationViewModel, rootViewModel: RootViewModel) { + self.viewModel = viewModel + self.rootViewModel = rootViewModel + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let timelinesViewController = TimelinesViewController( + viewModel: viewModel, + rootViewModel: rootViewModel) + let timelinesNavigationController = UINavigationController(rootViewController: timelinesViewController) + + if let notificationsViewModel = viewModel.notificationsViewModel, + let conversationsViewModel = viewModel.conversationsViewModel { + let notificationsViewController = TableViewController( + viewModel: notificationsViewModel, + rootViewModel: rootViewModel, + identification: viewModel.identification) + + notificationsViewController.tabBarItem = NavigationViewModel.Tab.notifications.tabBarItem + + let notificationsNavigationViewController = UINavigationController( + rootViewController: notificationsViewController) + + let conversationsViewController = TableViewController( + viewModel: conversationsViewModel, + rootViewModel: rootViewModel, + identification: viewModel.identification) + + conversationsViewController.tabBarItem = NavigationViewModel.Tab.messages.tabBarItem + conversationsViewController.navigationItem.title = NavigationViewModel.Tab.messages.title + + let conversationsNavigationViewController = UINavigationController( + rootViewController: conversationsViewController) + + viewControllers = [ + timelinesNavigationController, + notificationsNavigationViewController, + conversationsNavigationViewController + ] + } else { + viewControllers = [ + timelinesNavigationController + ] + } + } +} diff --git a/View Controllers/TimelinesViewController.swift b/View Controllers/TimelinesViewController.swift new file mode 100644 index 0000000..cdd1d6b --- /dev/null +++ b/View Controllers/TimelinesViewController.swift @@ -0,0 +1,131 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Combine +import UIKit +import ViewModels + +final class TimelinesViewController: UIPageViewController { + private let titleView: TimelinesTitleView + private let timelineViewControllers: [TableViewController] + private let viewModel: NavigationViewModel + private let rootViewModel: RootViewModel + private var cancellables = Set() + + init(viewModel: NavigationViewModel, rootViewModel: RootViewModel) { + self.viewModel = viewModel + self.rootViewModel = rootViewModel + + let timelineViewModels: [CollectionViewModel] + + if let homeTimelineViewModel = viewModel.homeTimelineViewModel { + timelineViewModels = [ + homeTimelineViewModel, + viewModel.localTimelineViewModel, + viewModel.federatedTimelineViewModel] + } else { + timelineViewModels = [ + viewModel.localTimelineViewModel, + viewModel.federatedTimelineViewModel] + } + + 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) + } + + super.init(transitionStyle: .scroll, + navigationOrientation: .horizontal, + options: [.interPageSpacing: CGFloat.defaultSpacing]) + + if let firstViewController = timelineViewControllers.first { + setViewControllers([firstViewController], direction: .forward, animated: false) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + dataSource = self + delegate = self + + tabBarItem = UITabBarItem( + title: NSLocalizedString("main-navigation.timelines", comment: ""), + 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 + guard let self = self, + let currentViewController = self.viewControllers?.first as? TableViewController, + let currentIndex = self.timelineViewControllers.firstIndex(of: currentViewController), + index != currentIndex + else { return } + + self.setViewControllers( + [self.timelineViewControllers[index]], + direction: index > currentIndex ? .forward : .reverse, + animated: !UIAccessibility.isReduceMotionEnabled) + } + .store(in: &cancellables) + } +} + +extension TimelinesViewController: UIPageViewControllerDataSource { + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard + let timelineViewController = viewController as? TableViewController, + let index = timelineViewControllers.firstIndex(of: timelineViewController), + index + 1 < timelineViewControllers.count + else { return nil } + + return timelineViewControllers[index + 1] + } + + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard + let timelineViewController = viewController as? TableViewController, + let index = timelineViewControllers.firstIndex(of: timelineViewController), + index > 0 + else { return nil } + + return timelineViewControllers[index - 1] + } +} + +extension TimelinesViewController: UIPageViewControllerDelegate { + func pageViewController(_ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool) { + guard let viewController = viewControllers?.first as? TableViewController, + let index = timelineViewControllers.firstIndex(of: viewController) + else { return } + + let timeline = titleView.timelines[index] + + if titleView.selectedTimeline != timeline { + titleView.selectedTimeline = timeline + } + } +} diff --git a/ViewModels/Sources/ViewModels/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/NavigationViewModel.swift index c358fb7..237127a 100644 --- a/ViewModels/Sources/ViewModels/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/NavigationViewModel.swift @@ -21,38 +21,56 @@ public final class NavigationViewModel: ObservableObject { @Published public var alertItem: AlertItem? public private(set) var timelineViewModel: CollectionItemsViewModel - public var notificationsViewModel: CollectionViewModel? { + public lazy var homeTimelineViewModel: CollectionViewModel? = { if identification.identity.authenticated { - if _notificationsViewModel == nil { - _notificationsViewModel = CollectionItemsViewModel( + 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 { + let notificationsViewModel = CollectionItemsViewModel( collectionService: identification.service.notificationsService(), identification: identification) - _notificationsViewModel?.request(maxId: nil, minId: nil) - } - return _notificationsViewModel + notificationsViewModel.request(maxId: nil, minId: nil) + + return notificationsViewModel } else { return nil } - } + }() - public var conversationsViewModel: CollectionViewModel? { + public lazy var conversationsViewModel: CollectionViewModel? = { if identification.identity.authenticated { - if _conversationsViewModel == nil { - _conversationsViewModel = CollectionItemsViewModel( + let conversationsViewModel = CollectionItemsViewModel( collectionService: identification.service.conversationsService(), identification: identification) - _conversationsViewModel?.request(maxId: nil, minId: nil) - } - return _conversationsViewModel + conversationsViewModel.request(maxId: nil, minId: nil) + + return conversationsViewModel } else { return nil } - } + }() - private var _notificationsViewModel: CollectionViewModel? - private var _conversationsViewModel: CollectionViewModel? private var cancellables = Set() public init(identification: Identification) { diff --git a/Views/MainNavigationView.swift b/Views/MainNavigationView.swift new file mode 100644 index 0000000..9779c39 --- /dev/null +++ b/Views/MainNavigationView.swift @@ -0,0 +1,32 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import SwiftUI +import ViewModels + +struct MainNavigationView: UIViewControllerRepresentable { + let viewModelClosure: () -> NavigationViewModel + @EnvironmentObject var rootViewModel: RootViewModel + @EnvironmentObject var identification: Identification + + func makeUIViewController(context: Context) -> MainNavigationViewController { + MainNavigationViewController( + viewModel: viewModelClosure(), + rootViewModel: rootViewModel) + } + + func updateUIViewController(_ uiViewController: MainNavigationViewController, context: Context) { + + } +} + +#if DEBUG +import PreviewViewModels + +struct MainNavigationView_Previews: PreviewProvider { + static var previews: some View { + MainNavigationView { NavigationViewModel(identification: .preview) } + .environmentObject(Identification.preview) + .environmentObject(RootViewModel.preview) + } +} +#endif diff --git a/Views/RootView.swift b/Views/RootView.swift index 381e3ca..6a673a4 100644 --- a/Views/RootView.swift +++ b/Views/RootView.swift @@ -8,10 +8,11 @@ struct RootView: View { var body: some View { if let navigationViewModel = viewModel.navigationViewModel { - TabNavigationView(viewModel: navigationViewModel) + MainNavigationView { navigationViewModel } .id(navigationViewModel.identification.identity.id) .environmentObject(viewModel) .transition(.opacity) + .edgesIgnoringSafeArea(.all) } else { NavigationView { AddIdentityView(viewModel: viewModel.addIdentityViewModel()) diff --git a/Views/TabNavigationView.swift b/Views/TabNavigationView.swift index 28bc2ef..876a7af 100644 --- a/Views/TabNavigationView.swift +++ b/Views/TabNavigationView.swift @@ -188,7 +188,8 @@ private extension TabNavigationView { } } -private extension Timeline { +// TODO: move +extension Timeline { var title: String { switch self { case .home: @@ -210,10 +211,40 @@ private extension Timeline { } } + 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 "person.3" + case .local: return "building.2.crop.circle" case .federated: return "network" case .list: return "scroll" case .tag: return "number" @@ -224,26 +255,6 @@ private extension Timeline { } } -extension NavigationViewModel.Tab { - var title: String { - switch self { - case .timelines: return "Timelines" - case .explore: return "Explore" - case .notifications: return "Notifications" - case .messages: return "Messages" - } - } - - var systemImageName: String { - switch self { - case .timelines: return "newspaper" - case .explore: return "magnifyingglass" - case .notifications: return "bell" - case .messages: return "envelope" - } - } -} - #if DEBUG import PreviewViewModels diff --git a/Views/TimelinesTitleView.swift b/Views/TimelinesTitleView.swift new file mode 100644 index 0000000..5ca4185 --- /dev/null +++ b/Views/TimelinesTitleView.swift @@ -0,0 +1,153 @@ +// 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 41f2aeb..2aa90e5 100644 --- a/Views/ViewConstants.swift +++ b/Views/ViewConstants.swift @@ -11,8 +11,10 @@ extension CGFloat { static let hairline = 1 / UIScreen.main.scale static let minimumButtonDimension: Self = 44 static let barButtonItemDimension: Self = 28 - static let newStatusButtonDimension: CGFloat = 54 - static let defaultShadowRadius: CGFloat = 2 + static let newStatusButtonDimension: Self = 54 + static let defaultShadowRadius: Self = 2 + static let systemMenuWidth: Self = 250 + static let systemMenuInset: Self = 15 } extension CGRect {