1
0
mirror of https://github.com/mastodon/mastodon-ios.git synced 2024-12-23 07:26:34 +01:00

feat: update wizard for new iPad design

This commit is contained in:
CMK 2021-11-02 19:15:46 +08:00
parent 30b2a35b84
commit 865718351d
9 changed files with 371 additions and 168 deletions

View File

@ -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 = "<group>"; };
DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewModel.swift; sourceTree = "<group>"; };
DB63BE7E268DD1070011D3F9 /* NotificationViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewController+StatusProvider.swift"; sourceTree = "<group>"; };
DB647C5626F1E97300F7F82C /* MainTabBarController+Wizard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainTabBarController+Wizard.swift"; sourceTree = "<group>"; };
DB647C5826F1EA2700F7F82C /* WizardPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardPreference.swift; sourceTree = "<group>"; };
DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+DataSource.swift"; sourceTree = "<group>"; };
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = "<group>"; };
DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = "<group>"; };
DB67D08327312970006A36CF /* APIService+Following.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Following.swift"; sourceTree = "<group>"; };
DB67D08527312E67006A36CF /* WizardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardViewController.swift; sourceTree = "<group>"; };
DB68045A2636DC6A00430867 /* MastodonNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonNotification.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@ -2529,6 +2529,14 @@
path = Image;
sourceTree = "<group>";
};
DB67D08727312E6A006A36CF /* Wizard */ = {
isa = PBXGroup;
children = (
DB67D08527312E67006A36CF /* WizardViewController.swift */,
);
path = Wizard;
sourceTree = "<group>";
};
DB6804802637CD4C00430867 /* AppShared */ = {
isa = PBXGroup;
children = (
@ -2770,7 +2778,6 @@
isa = PBXGroup;
children = (
DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */,
DB647C5626F1E97300F7F82C /* MainTabBarController+Wizard.swift */,
);
path = MainTab;
sourceTree = "<group>";
@ -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 */,

View File

@ -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<Int>()
@ -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) {

View File

@ -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")
}

View File

@ -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()
}
}

View File

@ -21,8 +21,6 @@ class MainTabBarController: UITabBarController {
static let avatarButtonSize = CGSize(width: 25, height: 25)
let avatarButton = CircleAvatarButton()
let wizard = Wizard()
var currentTab = CurrentValueSubject<Tab, Never>(.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)
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -23,6 +23,7 @@ final class SidebarViewModel {
// output
var diffableDataSource: UICollectionViewDiffableDataSource<Section, Item>?
var secondaryDiffableDataSource: UICollectionViewDiffableDataSource<Section, Item>?
private(set) var isReadyForWizardAvatarButton = false
let activeMastodonAuthenticationObjectID = CurrentValueSubject<NSManagedObjectID?, Never>(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<Section, Item>(collectionView: secondaryCollectionView) { collectionView, indexPath, item in

View File

@ -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<AnyCancellable>()
weak var delegate: WizardViewControllerDelegate?
private(set) var items: [Item] = {
var items: [Item] = []
if !UserDefaults.shared.didShowMultipleAccountSwitchWizard {
items.append(.multipleAccountSwitch)
}
return items
}()
let pendingItem = CurrentValueSubject<Item?, Never>(nil)
let currentItem = CurrentValueSubject<Item?, Never>(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()
}
}