diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 98cff7ec9..619baa12b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -315,12 +315,12 @@ DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */; }; DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */; }; DB63BE7F268DD1070011D3F9 /* NotificationViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB63BE7E268DD1070011D3F9 /* NotificationViewController+StatusProvider.swift */; }; - DB647C5726F1E97300F7F82C /* MainTabBarController+Wizard.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB647C5626F1E97300F7F82C /* MainTabBarController+Wizard.swift */; }; DB647C5926F1EA2700F7F82C /* WizardPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB647C5826F1EA2700F7F82C /* WizardPreference.swift */; }; DB66728C25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */; }; DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; DB67D08427312970006A36CF /* APIService+Following.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB67D08327312970006A36CF /* APIService+Following.swift */; }; + DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB67D08527312E67006A36CF /* WizardViewController.swift */; }; DB68045B2636DC6A00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; DB6804662636DC9000430867 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; @@ -1135,12 +1135,12 @@ DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewingViewController.swift; sourceTree = ""; }; DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewModel.swift; sourceTree = ""; }; DB63BE7E268DD1070011D3F9 /* NotificationViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewController+StatusProvider.swift"; sourceTree = ""; }; - DB647C5626F1E97300F7F82C /* MainTabBarController+Wizard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainTabBarController+Wizard.swift"; sourceTree = ""; }; DB647C5826F1EA2700F7F82C /* WizardPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardPreference.swift; sourceTree = ""; }; DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+DataSource.swift"; sourceTree = ""; }; DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; DB67D08327312970006A36CF /* APIService+Following.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Following.swift"; sourceTree = ""; }; + DB67D08527312E67006A36CF /* WizardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardViewController.swift; sourceTree = ""; }; DB68045A2636DC6A00430867 /* MastodonNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonNotification.swift; sourceTree = ""; }; DB68047F2637CD4C00430867 /* AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB6804812637CD4C00430867 /* AppShared.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppShared.h; sourceTree = ""; }; @@ -2529,6 +2529,14 @@ path = Image; sourceTree = ""; }; + DB67D08727312E6A006A36CF /* Wizard */ = { + isa = PBXGroup; + children = ( + DB67D08527312E67006A36CF /* WizardViewController.swift */, + ); + path = Wizard; + sourceTree = ""; + }; DB6804802637CD4C00430867 /* AppShared */ = { isa = PBXGroup; children = ( @@ -2770,7 +2778,6 @@ isa = PBXGroup; children = ( DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */, - DB647C5626F1E97300F7F82C /* MainTabBarController+Wizard.swift */, ); path = MainTab; sourceTree = ""; @@ -2782,6 +2789,7 @@ DB6180E426391A500018D199 /* Transition */, DB852D1D26FB021900FC9D81 /* Root */, DB01409B25C40BB600F9F3CF /* Onboarding */, + DB67D08727312E6A006A36CF /* Wizard */, DB9F58ED26EF435800E7BBE9 /* Account */, 2D38F1D325CD463600561493 /* HomeTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, @@ -4358,6 +4366,7 @@ DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */, DBBC24C026A5443100398BB9 /* MastodonTheme.swift in Sources */, + DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */, DBBC24B526A540AE00398BB9 /* AvatarConfigurableView.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, DBFEF07726A691FB006D7ED1 /* MastodonAuthenticationBox.swift in Sources */, @@ -4371,7 +4380,6 @@ DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, - DB647C5726F1E97300F7F82C /* MainTabBarController+Wizard.swift in Sources */, DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */, DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB6B74F8272FBFB100C70B6E /* FollowerListViewController+Provider.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index e7611056f..9fbb2b774 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -23,6 +23,7 @@ final public class SceneCoordinator { private(set) weak var tabBarController: MainTabBarController! private(set) weak var splitViewController: RootSplitViewController? + private(set) var wizardViewController: WizardViewController? private(set) var secondaryStackHashValues = Set() @@ -221,17 +222,34 @@ extension SceneCoordinator { extension SceneCoordinator { func setup() { + let rootViewController: UIViewController switch UIDevice.current.userInterfaceIdiom { case .phone: let viewController = MainTabBarController(context: appContext, coordinator: self) - sceneDelegate.window?.rootViewController = viewController - tabBarController = viewController + self.splitViewController = nil + self.tabBarController = viewController + rootViewController = viewController default: let splitViewController = RootSplitViewController(context: appContext, coordinator: self) self.splitViewController = splitViewController self.tabBarController = splitViewController.contentSplitViewController.mainTabBarController - sceneDelegate.window?.rootViewController = splitViewController + rootViewController = splitViewController } + + let wizardViewController = WizardViewController() + if !wizardViewController.items.isEmpty, + let delegate = rootViewController as? WizardViewControllerDelegate + { + // do not add as child view controller. + // otherwise, the tab bar controller will add as a new tab + wizardViewController.delegate = delegate + wizardViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + wizardViewController.view.frame = rootViewController.view.bounds + rootViewController.view.addSubview(wizardViewController.view) + self.wizardViewController = wizardViewController + } + + sceneDelegate.window?.rootViewController = rootViewController } func setupOnboardingIfNeeds(animated: Bool) { diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift index 024fb205d..6f18afc94 100644 --- a/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift +++ b/Mastodon/Scene/Onboarding/Welcome/View/WizardCardView.swift @@ -96,6 +96,13 @@ extension WizardCardView { path.addArc(withCenter: CGPoint(x: rect.minX, y: rect.minY), radius: radius, startAngle: -.pi / 2, endAngle: -.pi, clockwise: false) path.addLine(to: CGPoint(x: rect.minX - radius, y: rect.maxY + radius + WizardCardView.bubbleArrowHeight)) path.close() + case .allCorners: // no arrow + path.move(to: CGPoint(x: rect.maxX, y: rect.maxY + radius)) + path.addArc(withCenter: CGPoint(x: rect.minX, y: rect.maxY), radius: radius, startAngle: .pi / 2, endAngle: .pi, clockwise: true) + path.addArc(withCenter: CGPoint(x: rect.minX, y: rect.minY), radius: radius, startAngle: .pi, endAngle: .pi / 2 * 3, clockwise: true) + path.addArc(withCenter: CGPoint(x: rect.maxX, y: rect.minY), radius: radius, startAngle: .pi / 2 * 3, endAngle: .pi * 2, clockwise: true) + path.addArc(withCenter: CGPoint(x: rect.maxX, y: rect.maxY), radius: radius, startAngle: .pi * 2, endAngle: .pi / 2 * 5, clockwise: true) + path.close() default: assertionFailure("FIXME") } diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController+Wizard.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController+Wizard.swift deleted file mode 100644 index b69a6b786..000000000 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController+Wizard.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// MainTabBarController+Wizard.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-9-15. -// - -import os.log -import UIKit - -protocol WizardDelegate: AnyObject { - func spotlight(item: MainTabBarController.Wizard.Item) -> UIBezierPath - func layoutWizardCard(_ wizard: MainTabBarController.Wizard, item: MainTabBarController.Wizard.Item) -} - -extension MainTabBarController { - class Wizard { - - let logger = Logger(subsystem: "Wizard", category: "UI") - - weak var delegate: WizardDelegate? - - private(set) var items: [Item] - - let backgroundView: UIView = { - let view = UIView() - view.backgroundColor = UIColor.black.withAlphaComponent(0.7) - return view - }() - - init() { - var items: [Item] = [] - if !UserDefaults.shared.didShowMultipleAccountSwitchWizard { - items.append(.multipleAccountSwitch) - } - self.items = items - - let backgroundTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - backgroundTapGestureRecognizer.addTarget(self, action: #selector(MainTabBarController.Wizard.backgroundTapGestureRecognizerHandler(_:))) - backgroundView.addGestureRecognizer(backgroundTapGestureRecognizer) - } - } -} - -extension MainTabBarController.Wizard { - enum Item { - case multipleAccountSwitch - - var title: String { - return L10n.Scene.Wizard.newInMastodon - } - - var description: String { - switch self { - case .multipleAccountSwitch: - return L10n.Scene.Wizard.multipleAccountSwitchIntroDescription - } - } - - func markAsRead() { - switch self { - case .multipleAccountSwitch: - UserDefaults.shared.didShowMultipleAccountSwitchWizard = true - } - } - } -} - -extension MainTabBarController.Wizard { - - func setup(in view: UIView) { - assert(delegate != nil, "need set delegate before use") - - guard !items.isEmpty else { return } - - backgroundView.frame = view.bounds - backgroundView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(backgroundView) - NSLayoutConstraint.activate([ - backgroundView.topAnchor.constraint(equalTo: view.topAnchor), - backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - } - - func consume() { - guard !items.isEmpty else { - backgroundView.removeFromSuperview() - return - } - let item = items.removeFirst() - perform(item: item) - } - - private func perform(item: Item) { - guard let delegate = delegate else { - assertionFailure() - return - } - - // prepare for reuse - prepareForReuse() - - // set wizard item read - item.markAsRead() - - // add spotlight - let spotlight = delegate.spotlight(item: item) - let maskLayer = CAShapeLayer() - let path = UIBezierPath(rect: backgroundView.bounds) - path.append(spotlight) - maskLayer.fillRule = .evenOdd - maskLayer.path = path.cgPath - backgroundView.layer.mask = maskLayer - - // layout wizard card - delegate.layoutWizardCard(self, item: item) - } - - private func prepareForReuse() { - backgroundView.subviews.forEach { subview in - subview.removeFromSuperview() - } - backgroundView.mask = nil - backgroundView.layer.mask = nil - } - -} - -extension MainTabBarController.Wizard { - @objc private func backgroundTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - - consume() - } -} diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index e76db6b3a..2cb964277 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -21,8 +21,6 @@ class MainTabBarController: UITabBarController { static let avatarButtonSize = CGSize(width: 25, height: 25) let avatarButton = CircleAvatarButton() - - let wizard = Wizard() var currentTab = CurrentValueSubject(.home) @@ -108,6 +106,8 @@ class MainTabBarController: UITabBarController { var _viewControllers: [UIViewController] = [] + private(set) var isReadyForWizardAvatarButton = false + init(context: AppContext, coordinator: SceneCoordinator) { self.context = context self.coordinator = coordinator @@ -247,9 +247,6 @@ extension MainTabBarController { profileTabItem.accessibilityHint = L10n.Scene.AccountList.tabBarHint(currentUserDisplayName) } .store(in: &disposeBag) - - wizard.delegate = self - wizard.setup(in: view) let tabBarLongPressGestureRecognizer = UILongPressGestureRecognizer() tabBarLongPressGestureRecognizer.addTarget(self, action: #selector(MainTabBarController.tabBarLongPressGestureRecognizerHandler(_:))) @@ -265,7 +262,7 @@ extension MainTabBarController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - wizard.consume() + isReadyForWizardAvatarButton = true } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -377,51 +374,57 @@ extension MainTabBarController: UITabBarControllerDelegate { } } -// MARK: - WizardDataSource -extension MainTabBarController: WizardDelegate { - func spotlight(item: Wizard.Item) -> UIBezierPath { +// MARK: - WizardViewControllerDelegate +extension MainTabBarController: WizardViewControllerDelegate { + func readyToLayoutItem(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> Bool { switch item { case .multipleAccountSwitch: - guard let avatarButtonFrameInView = avatarButtonFrameInView() else { - return UIBezierPath() - } - return UIBezierPath(ovalIn: avatarButtonFrameInView) - + return isReadyForWizardAvatarButton } } - func layoutWizardCard(_ wizard: MainTabBarController.Wizard, item: Wizard.Item) { + func layoutSpotlight(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> UIBezierPath { switch item { case .multipleAccountSwitch: - guard let avatarButtonFrameInView = avatarButtonFrameInView() else { + guard let avatarButtonFrameInView = avatarButtonFrameInWizardView(wizardView: wizardViewController.view) else { + return UIBezierPath() + } + return UIBezierPath(ovalIn: avatarButtonFrameInView) + } + } + + func layoutWizardCard(_ wizardViewController: WizardViewController, item: WizardViewController.Item) { + switch item { + case .multipleAccountSwitch: + guard let avatarButtonFrameInView = avatarButtonFrameInWizardView(wizardView: wizardViewController.view) else { return } let anchorView = UIView() anchorView.frame = avatarButtonFrameInView - wizard.backgroundView.addSubview(anchorView) + wizardViewController.backgroundView.addSubview(anchorView) let wizardCardView = WizardCardView() wizardCardView.arrowRectCorner = view.traitCollection.layoutDirection == .leftToRight ? .bottomRight : .bottomLeft wizardCardView.titleLabel.text = item.title wizardCardView.descriptionLabel.text = item.description - + wizardCardView.translatesAutoresizingMaskIntoConstraints = false - wizard.backgroundView.addSubview(wizardCardView) + wizardViewController.backgroundView.addSubview(wizardCardView) NSLayoutConstraint.activate([ anchorView.topAnchor.constraint(equalTo: wizardCardView.bottomAnchor, constant: 13), // 13pt spacing wizardCardView.trailingAnchor.constraint(equalTo: anchorView.centerXAnchor), - wizardCardView.widthAnchor.constraint(equalTo: wizard.backgroundView.widthAnchor, multiplier: 2.0/3.0).priority(.required - 1), + wizardCardView.widthAnchor.constraint(equalTo: wizardViewController.view.widthAnchor, multiplier: 2.0/3.0).priority(.required - 1), ]) wizardCardView.setContentHuggingPriority(.defaultLow, for: .vertical) } } - private func avatarButtonFrameInView() -> CGRect? { + private func avatarButtonFrameInWizardView(wizardView: UIView) -> CGRect? { guard let superview = avatarButton.superview else { assertionFailure() return nil } - return superview.convert(avatarButton.frame, to: view) + return superview.convert(avatarButton.frame, to: wizardView) } } diff --git a/Mastodon/Scene/Root/RootSplitViewController.swift b/Mastodon/Scene/Root/RootSplitViewController.swift index 13df6f889..2b3c858e0 100644 --- a/Mastodon/Scene/Root/RootSplitViewController.swift +++ b/Mastodon/Scene/Root/RootSplitViewController.swift @@ -241,3 +241,90 @@ extension RootSplitViewController: UISplitViewControllerDelegate { } } + +// MARK: - WizardViewControllerDelegate +extension RootSplitViewController: WizardViewControllerDelegate { + + func readyToLayoutItem(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> Bool { + guard traitCollection.horizontalSizeClass != .compact else { + return compactMainTabBarViewController.readyToLayoutItem(wizardViewController, item: item) + } + + switch item { + case .multipleAccountSwitch: + return contentSplitViewController.sidebarViewController.viewModel.isReadyForWizardAvatarButton + } + } + + + func layoutSpotlight(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> UIBezierPath { + guard traitCollection.horizontalSizeClass != .compact else { + return compactMainTabBarViewController.layoutSpotlight(wizardViewController, item: item) + } + + switch item { + case .multipleAccountSwitch: + guard let frame = avatarButtonFrameInWizardView(wizardView: wizardViewController.view) + else { + assertionFailure() + return UIBezierPath() + } + return UIBezierPath(ovalIn: frame) + } + } + + func layoutWizardCard(_ wizardViewController: WizardViewController, item: WizardViewController.Item) { + guard traitCollection.horizontalSizeClass != .compact else { + return compactMainTabBarViewController.layoutWizardCard(wizardViewController, item: item) + } + + guard let frame = avatarButtonFrameInWizardView(wizardView: wizardViewController.view) else { + return + } + + let anchorView = UIView() + anchorView.frame = frame + wizardViewController.backgroundView.addSubview(anchorView) + + let wizardCardView = WizardCardView() + wizardCardView.arrowRectCorner = .allCorners // no arrow + wizardCardView.titleLabel.text = item.title + wizardCardView.descriptionLabel.text = item.description + + wizardCardView.translatesAutoresizingMaskIntoConstraints = false + wizardViewController.backgroundView.addSubview(wizardCardView) + NSLayoutConstraint.activate([ + wizardCardView.centerYAnchor.constraint(equalTo: anchorView.centerYAnchor), + wizardCardView.leadingAnchor.constraint(equalTo: anchorView.trailingAnchor, constant: 20), // 20pt spacing + wizardCardView.widthAnchor.constraint(equalToConstant: 320), + ]) + wizardCardView.setContentHuggingPriority(.defaultLow, for: .vertical) + } + + private func avatarButtonFrameInWizardView(wizardView: UIView) -> CGRect? { + guard let diffableDataSource = contentSplitViewController.sidebarViewController.viewModel.diffableDataSource, + let indexPath = diffableDataSource.indexPath(for: .tab(.me)), + let cell = contentSplitViewController.sidebarViewController.collectionView.cellForItem(at: indexPath) as? SidebarListCollectionViewCell, + let contentView = cell._contentView, + let frame = sourceViewFrameInTargetView( + sourceView: contentView.avatarButton, + targetView: wizardView + ) + else { + assertionFailure() + return nil + } + return frame + } + + private func sourceViewFrameInTargetView( + sourceView: UIView, + targetView: UIView + ) -> CGRect? { + guard let superview = sourceView.superview else { + assertionFailure() + return nil + } + return superview.convert(sourceView.frame, to: targetView) + } +} diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift index 7958c5080..d2dae2478 100644 --- a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift +++ b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift @@ -125,6 +125,7 @@ extension SidebarViewController { secondaryCollectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] secondaryCollectionView, _ in guard let self = self else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): secondaryCollectionView contentSize: \(secondaryCollectionView.contentSize.debugDescription)") let height = secondaryCollectionView.contentSize.height self.secondaryCollectionViewHeightLayoutConstraint.constant = height self.collectionView.contentInset.bottom = height diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift index 83abf4e6b..1f982429e 100644 --- a/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift +++ b/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift @@ -23,6 +23,7 @@ final class SidebarViewModel { // output var diffableDataSource: UICollectionViewDiffableDataSource? var secondaryDiffableDataSource: UICollectionViewDiffableDataSource? + private(set) var isReadyForWizardAvatarButton = false let activeMastodonAuthenticationObjectID = CurrentValueSubject(nil) @@ -170,8 +171,12 @@ extension SidebarViewModel { .setting, ] sectionSnapshot.append(items, to: nil) - _diffableDataSource.apply(sectionSnapshot, to: .main) - + // animatingDifferences must to be `true` + // otherwise the UI layout will infinity loop + _diffableDataSource.apply(sectionSnapshot, to: .main, animatingDifferences: true) { [weak self] in + guard let self = self else { return } + self.isReadyForWizardAvatarButton = true + } // secondary let _secondaryDiffableDataSource = UICollectionViewDiffableDataSource(collectionView: secondaryCollectionView) { collectionView, indexPath, item in diff --git a/Mastodon/Scene/Wizard/WizardViewController.swift b/Mastodon/Scene/Wizard/WizardViewController.swift new file mode 100644 index 000000000..2678c712d --- /dev/null +++ b/Mastodon/Scene/Wizard/WizardViewController.swift @@ -0,0 +1,211 @@ +// +// WizardViewController.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-11-2. +// + +import os.log +import UIKit +import Combine + +protocol WizardViewControllerDelegate: AnyObject { + func readyToLayoutItem(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> Bool + func layoutSpotlight(_ wizardViewController: WizardViewController, item: WizardViewController.Item) -> UIBezierPath + func layoutWizardCard(_ wizardViewController: WizardViewController, item: WizardViewController.Item) +} + +class WizardViewController: UIViewController { + + let logger = Logger(subsystem: "Wizard", category: "UI") + + var disposeBag = Set() + weak var delegate: WizardViewControllerDelegate? + + private(set) var items: [Item] = { + var items: [Item] = [] + if !UserDefaults.shared.didShowMultipleAccountSwitchWizard { + items.append(.multipleAccountSwitch) + } + return items + }() + + let pendingItem = CurrentValueSubject(nil) + let currentItem = CurrentValueSubject(nil) + + let backgroundView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.7) + return view + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension WizardViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + setup() + + let backgroundTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + backgroundTapGestureRecognizer.addTarget(self, action: #selector(WizardViewController.backgroundTapGestureRecognizerHandler(_:))) + backgroundView.addGestureRecognizer(backgroundTapGestureRecognizer) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // Create a timer to consume pending item + Timer.publish(every: 0.5, on: .main, in: .default) + .autoconnect() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + guard self.pendingItem.value != nil else { return } + self.consume() + } + .store(in: &disposeBag) + + consume() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + invalidLayoutForCurrentItem() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate { context in + + } completion: { [weak self] context in + guard let self = self else { return } + self.invalidLayoutForCurrentItem() + } + + } + +} + +extension WizardViewController { + enum Item { + case multipleAccountSwitch + + var title: String { + return L10n.Scene.Wizard.newInMastodon + } + + var description: String { + switch self { + case .multipleAccountSwitch: + return L10n.Scene.Wizard.multipleAccountSwitchIntroDescription + } + } + + func markAsRead() { + switch self { + case .multipleAccountSwitch: + UserDefaults.shared.didShowMultipleAccountSwitchWizard = true + } + } + } +} + +extension WizardViewController { + + func setup() { + assert(delegate != nil, "need set delegate before use") + + guard !items.isEmpty else { return } + + backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + backgroundView.frame = view.bounds + view.addSubview(backgroundView) + } + + func destroy() { + view.removeFromSuperview() + } + + func consume() { + guard !items.isEmpty else { + destroy() + return + } + + guard let first = items.first else { return } + guard delegate?.readyToLayoutItem(self, item: first) == true else { + pendingItem.value = first + return + } + pendingItem.value = nil + currentItem.value = nil + + let item = items.removeFirst() + perform(item: item) + } + + private func perform(item: Item) { + guard let delegate = delegate else { + assertionFailure() + return + } + + // prepare for reuse + prepareForReuse() + + // set wizard item read + item.markAsRead() + + // add spotlight + let spotlight = delegate.layoutSpotlight(self, item: item) + let maskLayer = CAShapeLayer() + // expand rect to make sure view always fill the screen when device rotate + let expandRect: CGRect = { + var rect = backgroundView.bounds + rect.size.width *= 2 + rect.size.height *= 2 + return rect + }() + let path = UIBezierPath(rect: expandRect) + path.append(spotlight) + maskLayer.fillRule = .evenOdd + maskLayer.path = path.cgPath + backgroundView.layer.mask = maskLayer + + // layout wizard card + delegate.layoutWizardCard(self, item: item) + + currentItem.value = item + } + + private func prepareForReuse() { + backgroundView.subviews.forEach { subview in + subview.removeFromSuperview() + } + backgroundView.mask = nil + backgroundView.layer.mask = nil + } + + private func invalidLayoutForCurrentItem() { + if let item = currentItem.value { + perform(item: item) + } + } + +} + +extension WizardViewController { + @objc private func backgroundTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + + consume() + } +}