feat(App): Implement Haptic Feedback for Tab switches and Timeline reload
This commit is contained in:
parent
6362eea3b9
commit
9a3ba02683
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
])
|
])
|
||||||
|
|
|
@ -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`.
|
||||||
|
|
Loading…
Reference in New Issue