feat: add scroll position record and shortcut bar

This commit is contained in:
CMK 2021-09-28 19:58:14 +08:00
parent 25d6515c98
commit 3bc1a3de39
9 changed files with 245 additions and 28 deletions

View File

@ -573,6 +573,8 @@
DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; };
DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; };
DBF156DF2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF156DE2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift */; };
DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */ = {isa = PBXBuildFile; fileRef = DBF156E12702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m */; };
DBF156E42702DB3F00EC00B7 /* HandleTapAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF156E32702DB3F00EC00B7 /* HandleTapAction.swift */; };
DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */; };
DBF1D251269DB01200C1C08A /* SearchHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */; };
DBF1D257269DBAC600C1C08A /* SearchDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */; };
@ -1355,6 +1357,9 @@
DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = "<group>"; };
DBF156DD27006F5D00EC00B7 /* CoreData 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "CoreData 2.xcdatamodel"; sourceTree = "<group>"; };
DBF156DE2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarAddAccountCollectionViewCell.swift; sourceTree = "<group>"; };
DBF156E02702DA6800EC00B7 /* Mastodon-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Mastodon-Bridging-Header.h"; sourceTree = "<group>"; };
DBF156E12702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIStatusBarManager+HandleTapAction.m"; sourceTree = "<group>"; };
DBF156E32702DB3F00EC00B7 /* HandleTapAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandleTapAction.swift; sourceTree = "<group>"; };
DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewController.swift; sourceTree = "<group>"; };
DBF1D250269DB01200C1C08A /* SearchHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistoryViewController.swift; sourceTree = "<group>"; };
DBF1D256269DBAC600C1C08A /* SearchDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDetailViewModel.swift; sourceTree = "<group>"; };
@ -1737,6 +1742,9 @@
DB6180EC26391C6C0018D199 /* TransitioningMath.swift */,
DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */,
DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */,
DBF156E32702DB3F00EC00B7 /* HandleTapAction.swift */,
DBF156E12702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m */,
DBF156E02702DA6800EC00B7 /* Mastodon-Bridging-Header.h */,
);
path = Vender;
sourceTree = "<group>";
@ -3437,7 +3445,7 @@
TargetAttributes = {
DB427DD125BAA00100D1B89D = {
CreatedOnToolsVersion = 12.4;
LastSwiftMigration = 1220;
LastSwiftMigration = 1300;
};
DB427DE725BAA00100D1B89D = {
CreatedOnToolsVersion = 12.4;
@ -4067,6 +4075,7 @@
DB3667A1268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */,
DBF156E42702DB3F00EC00B7 /* HandleTapAction.swift in Sources */,
DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
DB4F096C269EFA2000D62E92 /* SearchResultViewController+StatusProvider.swift in Sources */,
@ -4114,6 +4123,7 @@
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */,
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */,
DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */,
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
@ -4758,6 +4768,7 @@
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Mastodon/Vender/Mastodon-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -4786,6 +4797,7 @@
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Mastodon/Vender/Mastodon-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
@ -5316,6 +5328,7 @@
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Mastodon/Vender/Mastodon-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -5552,6 +5565,7 @@
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Mastodon/Vender/Mastodon-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";

View File

@ -18,7 +18,7 @@ import MastodonUI
final class ComposeViewController: UIViewController, NeedsDependency {
static let minAutoCompleteVisibleHeight: CGFloat = 100
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -137,6 +137,16 @@ extension ComposeViewController {
override func viewDidLoad() {
super.viewDidLoad()
let groups = [UIBarButtonItemGroup(barButtonItems: [
composeToolbarView.mediaBarButtonItem,
composeToolbarView.pollBarButtonItem,
composeToolbarView.contentWarningBarButtonItem,
composeToolbarView.visibilityBarButtonItem,
], representativeItem: nil)]
tableView.inputAssistantItem.trailingBarButtonGroups = groups
textEditorView()?.textView.inputAssistantItem.trailingBarButtonGroups = groups
viewModel.title
.receive(on: DispatchQueue.main)
.sink { [weak self] title in
@ -330,13 +340,21 @@ extension ComposeViewController {
// bind media button toolbar state
viewModel.isMediaToolbarButtonEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: composeToolbarView.mediaButton)
.sink { [weak self] isMediaToolbarButtonEnabled in
guard let self = self else { return }
// self.composeToolbarView.mediaBarButtonItem.isEnabled = isMediaToolbarButtonEnabled
self.composeToolbarView.mediaButton.isEnabled = isMediaToolbarButtonEnabled
}
.store(in: &disposeBag)
// bind poll button toolbar state
viewModel.isPollToolbarButtonEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: composeToolbarView.pollButton)
.sink { [weak self] isPollToolbarButtonEnabled in
guard let self = self else { return }
// self.composeToolbarView.pollBarButtonItem.isEnabled = isPollToolbarButtonEnabled
self.composeToolbarView.pollButton.isEnabled = isPollToolbarButtonEnabled
}
.store(in: &disposeBag)
Publishers.CombineLatest(
@ -347,10 +365,14 @@ extension ComposeViewController {
.sink { [weak self] isPollComposing, isPollToolbarButtonEnabled in
guard let self = self else { return }
guard isPollToolbarButtonEnabled else {
self.composeToolbarView.pollButton.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll
let accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll
// self.composeToolbarView.pollBarButtonItem.accessibilityLabel = accessibilityLabel
self.composeToolbarView.pollButton.accessibilityLabel = accessibilityLabel
return
}
self.composeToolbarView.pollButton.accessibilityLabel = isPollComposing ? L10n.Scene.Compose.Accessibility.removePoll : L10n.Scene.Compose.Accessibility.appendPoll
let accessibilityLabel = isPollComposing ? L10n.Scene.Compose.Accessibility.removePoll : L10n.Scene.Compose.Accessibility.appendPoll
// self.composeToolbarView.pollBarButtonItem.accessibilityLabel = accessibilityLabel
self.composeToolbarView.pollButton.accessibilityLabel = accessibilityLabel
}
.store(in: &disposeBag)

View File

@ -27,6 +27,41 @@ final class ComposeToolbarView: UIView {
weak var delegate: ComposeToolbarViewDelegate?
// barButtonItem
let mediaBarButton: UIButton = {
let button = UIButton()
button.setImage(UIImage(systemName: "photo"), for: .normal)
return button
}()
private(set) lazy var mediaBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(customView: mediaBarButton)
barButtonItem.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendAttachment
return barButtonItem
}()
let pollBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.image = UIImage(systemName: "list.bullet")
barButtonItem.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll
return barButtonItem
}()
let contentWarningBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.image = UIImage(systemName: "exclamationmark.shield")
barButtonItem.accessibilityLabel = L10n.Scene.Compose.Accessibility.enableContentWarning
return barButtonItem
}()
let visibilityBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.image = UIImage(systemName: "person.3")
barButtonItem.accessibilityLabel = L10n.Scene.Compose.Accessibility.postVisibilityMenu
return barButtonItem
}()
// button
let mediaButton: UIButton = {
let button = HighlightDimmableButton()
ComposeToolbarView.configureToolbarButtonAppearance(button: button)

View File

@ -17,6 +17,8 @@ import AlamofireImage
final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
let logger = Logger(subsystem: "HomeTimelineViewController", category: "UI")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -218,6 +220,31 @@ extension HomeTimelineViewController {
}
}
.store(in: &disposeBag)
NotificationCenter.default
.publisher(for: .statusBarTapped, object: nil)
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false)
.sink { [weak self] notification in
guard let self = self else { return }
guard let _ = self.view.window else { return } // displaying
// https://developer.limneos.net/index.php?ios=13.1.3&framework=UIKitCore.framework&header=UIStatusBarTapAction.h
guard let action = notification.object as AnyObject?,
let xPosition = action.value(forKey: "xPosition") as? Double
else { return }
let viewFrameInWindow = self.view.convert(self.view.frame, to: nil)
guard xPosition >= viewFrameInWindow.minX && xPosition <= viewFrameInWindow.maxX else { return }
// works on iOS 14
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): receive notification \(xPosition)")
// check if scroll to top
guard self.shouldRestoreScrollPosition() else { return }
self.restorePositionWhenScrollToTop()
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
@ -234,9 +261,15 @@ extension HomeTimelineViewController {
viewModel.viewDidAppear.send()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
guard let self = self else { return }
// always try to refresh timeline after appear
if let timestamp = viewModel.lastAutomaticFetchTimestamp.value {
let now = Date()
if now.timeIntervalSince(timestamp) > 60 {
self.viewModel.lastAutomaticFetchTimestamp.value = now
self.viewModel.homeTimelineNeedRefresh.send()
} else {
// do nothing
}
} else {
self.viewModel.homeTimelineNeedRefresh.send()
}
}
@ -394,9 +427,62 @@ extension HomeTimelineViewController: TableViewCellHeightCacheableContainer {
// MARK: - UIScrollViewDelegate
extension HomeTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
switch scrollView {
case tableView:
aspectScrollViewDidScroll(scrollView)
viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView)
default:
break
}
}
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
switch scrollView {
case tableView:
// handle scrollToTop
savePositionBeforeScrollToTop()
return true
default:
assertionFailure()
return true
}
}
private func savePositionBeforeScrollToTop() {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let anchorIndexPaths = tableView.indexPathsForVisibleRows?.sorted() else { return }
guard !anchorIndexPaths.isEmpty else { return }
let anchorIndexPath = anchorIndexPaths[anchorIndexPaths.count / 2]
guard let anchorItem = diffableDataSource.itemIdentifier(for: anchorIndexPath) else { return }
aspectScrollViewDidScroll(scrollView)
viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView)
let offset: CGFloat = {
guard let anchorCell = tableView.cellForRow(at: anchorIndexPath) else { return 0 }
let cellFrameInView = tableView.convert(anchorCell.frame, to: view)
return cellFrameInView.origin.y
}()
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): save position record for \(anchorIndexPath) with offset: \(offset)")
viewModel.scrollPositionRecord.value = HomeTimelineViewModel.ScrollPositionRecord(
item: anchorItem,
offset: offset,
timestamp: Date()
)
}
private func shouldRestoreScrollPosition() -> Bool {
// check if scroll to top
guard self.tableView.safeAreaInsets.top > 0 else { return false }
let zeroOffset = -self.tableView.safeAreaInsets.top
return abs(self.tableView.contentOffset.y - zeroOffset) < 2.0
}
private func restorePositionWhenScrollToTop() {
guard let diffableDataSource = self.viewModel.diffableDataSource else { return }
guard let record = self.viewModel.scrollPositionRecord.value,
let indexPath = diffableDataSource.indexPath(for: record.item)
else { return }
self.tableView.scrollToRow(at: indexPath, at: .middle, animated: true)
self.viewModel.scrollPositionRecord.value = nil
}
}
@ -544,6 +630,8 @@ extension HomeTimelineViewController: ScrollViewContainer {
} else {
let indexPath = IndexPath(row: 0, section: 0)
guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return }
// save position
savePositionBeforeScrollToTop()
tableView.scrollToRow(at: indexPath, at: .top, animated: true)
}
}
@ -572,7 +660,12 @@ extension HomeTimelineViewController: StatusTableViewCellDelegate {
// MARK: - HomeTimelineNavigationBarTitleViewDelegate
extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate {
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) {
scrollToTop(animated: true)
if shouldRestoreScrollPosition() {
restorePositionWhenScrollToTop()
} else {
savePositionBeforeScrollToTop()
scrollToTop(animated: true)
}
}
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) {

View File

@ -28,6 +28,8 @@ final class HomeTimelineViewModel: NSObject {
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
let viewDidAppear = PassthroughSubject<Void, Never>()
let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel
let lastAutomaticFetchTimestamp = CurrentValueSubject<Date?, Never>(nil)
let scrollPositionRecord = CurrentValueSubject<ScrollPositionRecord?, Never>(nil)
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
weak var tableView: UITableView?
@ -153,3 +155,12 @@ final class HomeTimelineViewModel: NSObject {
}
extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { }
extension HomeTimelineViewModel {
struct ScrollPositionRecord {
let item: Item
let offset: CGFloat
let timestamp: Date
}
}

View File

@ -155,19 +155,3 @@ extension SceneDelegate {
return true
}
}
#if DEBUG
class TestWindow: UIWindow {
override func sendEvent(_ event: UIEvent) {
event.allTouches?.forEach({ (touch) in
let location = touch.location(in: self)
let view = hitTest(location, with: event)
print(view.debugDescription)
})
super.sendEvent(event)
}
}
#endif

View File

@ -0,0 +1,16 @@
//
// HandleTapAction.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-9-28.
//
import Foundation
@objc class HandleTapAction: NSObject {
@objc static let statusBarTappedNotification = Notification(name: .statusBarTapped)
}
extension Notification.Name {
static let statusBarTapped = Notification.Name(rawValue: "org.joinmastodon.app.statusBarTapped")
}

View File

@ -0,0 +1,4 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//

View File

@ -0,0 +1,38 @@
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <objc/message.h>
#import <objc/runtime.h>
@implementation UIStatusBarManager (CAPHandleTapAction)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];
SEL originalSelector = NSSelectorFromString(@"handleTapAction:");
SEL swizzledSelector = @selector(custom_handleTapAction:);
Method originalMethod = class_getInstanceMethod(self, originalSelector);
Method swizzledMethod = class_getInstanceMethod(self, swizzledSelector);
BOOL didAddMethod = class_addMethod(class,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {
class_replaceMethod(class,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
});
}
-(void)custom_handleTapAction:(id)sender {
[[NSNotificationCenter defaultCenter] postNotificationName:@"org.joinmastodon.app.statusBarTapped" object:sender];
[self custom_handleTapAction:sender];
}
@end