feat(App): Implement Haptic Feedback for Tab switches and Timeline reload

This commit is contained in:
Marcus Kida 2023-02-07 11:35:25 +01:00
parent 6362eea3b9
commit 9a3ba02683
No known key found for this signature in database
GPG Key ID: 19FF64E08013CA40
4 changed files with 79 additions and 50 deletions

View File

@ -394,7 +394,7 @@ extension HomeTimelineViewController {
} }
@objc private func refreshControlValueChanged(_ sender: RefreshControl) { @objc private func refreshControlValueChanged(_ sender: RefreshControl) {
guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else { guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.LoadingManually.self) else {
sender.endRefreshing() sender.endRefreshing()
return return
} }

View File

@ -61,54 +61,17 @@ extension HomeTimelineViewModel.LoadLatestState {
} }
override func didEnter(from previousState: GKState?) { override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState) didEnter(from: previousState, viewModel: viewModel, isUserInitiated: false)
guard let viewModel else { return } }
}
let latestFeedRecords = viewModel.fetchedResultsController.records.prefix(APIService.onceRequestStatusMaxCount)
let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext class LoadingManually: HomeTimelineViewModel.LoadLatestState {
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) override func isValidNextState(_ stateClass: AnyClass) -> Bool {
managedObjectContext.parent = parentManagedObjectContext return stateClass == Fail.self || stateClass == Idle.self
}
Task {
let start = CACurrentMediaTime() override func didEnter(from previousState: GKState?) {
let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in didEnter(from: previousState, viewModel: viewModel, isUserInitiated: true)
guard let feed = record.object(in: managedObjectContext) else { return nil }
return feed.status?.id
}
let end = CACurrentMediaTime()
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
do {
let response = try await viewModel.context.apiService.homeTimeline(
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
await enter(state: Idle.self)
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.finished)
viewModel.context.instanceService.updateMutesAndBlocks()
// stop refresher if no new statuses
let statuses = response.value
let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) }
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): load \(newStatuses.count) new statuses")
if newStatuses.isEmpty {
viewModel.didLoadLatest.send()
} else {
if !latestStatusIDs.isEmpty {
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
}
}
viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty
} catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statuses failed: \(error.localizedDescription)")
await enter(state: Idle.self)
viewModel.didLoadLatest.send()
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.failure(error))
}
} // end Task
} }
} }
@ -124,4 +87,60 @@ extension HomeTimelineViewModel.LoadLatestState {
} }
} }
private func didEnter(from previousState: GKState?, viewModel: HomeTimelineViewModel?, isUserInitiated: Bool) {
super.didEnter(from: previousState)
guard let viewModel else { return }
let latestFeedRecords = viewModel.fetchedResultsController.records.prefix(APIService.onceRequestStatusMaxCount)
let parentManagedObjectContext = viewModel.fetchedResultsController.managedObjectContext
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
managedObjectContext.parent = parentManagedObjectContext
Task {
let start = CACurrentMediaTime()
let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in
guard let feed = record.object(in: managedObjectContext) else { return nil }
return feed.status?.id
}
let end = CACurrentMediaTime()
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
do {
let response = try await viewModel.context.apiService.homeTimeline(
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
await enter(state: Idle.self)
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.finished)
viewModel.context.instanceService.updateMutesAndBlocks()
// stop refresher if no new statuses
let statuses = response.value
let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) }
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): load \(newStatuses.count) new statuses")
if newStatuses.isEmpty {
viewModel.didLoadLatest.send()
} else {
if !latestStatusIDs.isEmpty {
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
}
}
viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty
if !isUserInitiated {
await UIImpactFeedbackGenerator(style: .light)
.impactOccurred()
}
} catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statuses failed: \(error.localizedDescription)")
await enter(state: Idle.self)
viewModel.didLoadLatest.send()
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.failure(error))
}
} // end Task
}
} }

View File

@ -52,6 +52,7 @@ final class HomeTimelineViewModel: NSObject {
let stateMachine = GKStateMachine(states: [ let stateMachine = GKStateMachine(states: [
LoadLatestState.Initial(viewModel: self), LoadLatestState.Initial(viewModel: self),
LoadLatestState.Loading(viewModel: self), LoadLatestState.Loading(viewModel: self),
LoadLatestState.LoadingManually(viewModel: self),
LoadLatestState.Fail(viewModel: self), LoadLatestState.Fail(viewModel: self),
LoadLatestState.Idle(viewModel: self), LoadLatestState.Idle(viewModel: self),
]) ])

View File

@ -154,7 +154,10 @@ class MainTabBarController: UITabBarController {
// output // output
var avatarURLObserver: AnyCancellable? var avatarURLObserver: AnyCancellable?
@Published var avatarURL: URL? @Published var avatarURL: URL?
// haptic feedback
private let selectionFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
init( init(
context: AppContext, context: AppContext,
coordinator: SceneCoordinator, coordinator: SceneCoordinator,
@ -378,6 +381,7 @@ extension MainTabBarController {
@objc private func composeButtonDidPressed(_ sender: Any) { @objc private func composeButtonDidPressed(_ sender: Any) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
selectionFeedbackGenerator.impactOccurred()
guard let authContext = self.authContext else { return } guard let authContext = self.authContext else { return }
let composeViewModel = ComposeViewModel( let composeViewModel = ComposeViewModel(
context: context, context: context,
@ -572,6 +576,11 @@ extension MainTabBarController: UITabBarControllerDelegate {
composeButtonDidPressed(tabBarController) composeButtonDidPressed(tabBarController)
return false return false
} }
// Different tab has been selected, send haptic feedback
if viewController.tabBarItem.tag != tabBarController.selectedIndex {
selectionFeedbackGenerator.impactOccurred()
}
// Assert index is as same as the tab rawValue. This check needs to be done `shouldSelect` // Assert index is as same as the tab rawValue. This check needs to be done `shouldSelect`
// because the nav controller has already popped in `didSelect`. // because the nav controller has already popped in `didSelect`.