diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 251d22738..23b17547d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -110,7 +110,9 @@ struct RoomScreen: View { @ViewBuilder private var dateBadge: some View { if !isVoiceOverEnabled { - FloatingDateBadge(dateText: timelineContext.floatingDateText) + FloatingDateBadge(dateText: timelineContext.floatingDate?.formattedDateSeparator()) { + timelineContext.send(viewAction: .scrollToFirstItemForCurrentDate) + } } } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift index 2d212ed36..0aeb654d6 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift @@ -32,8 +32,10 @@ struct ThreadTimelineScreen: View { .toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background. .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel) .overlay(alignment: .top) { - FloatingDateBadge(dateText: timelineContext.floatingDateText) - .padding(.top, 13) + FloatingDateBadge(dateText: timelineContext.floatingDate?.formattedDateSeparator()) { + timelineContext.send(viewAction: .scrollToFirstItemForCurrentDate) + } + .padding(.top, 13) } .overlay(alignment: .bottomTrailing) { TimelineScrollToBottomButton(isVisible: isAtBottomAndLive) { diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 3e852c643..930e240eb 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -56,6 +56,7 @@ enum TimelineViewAction { case paginateBackwards case paginateForwards case scrollToBottom + case scrollToFirstItemForCurrentDate case displayTimelineItemMenu(itemID: TimelineItemIdentifier) case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction) @@ -152,8 +153,8 @@ struct TimelineViewState: BindableState { struct TimelineViewStateBindings { var isScrolledToBottom = true - /// The formatted date text for the floating date badge shown while scrolling. - var floatingDateText: String? + /// The timestamp of the topmost visible item, used to drive the floating date badge while scrolling. + var floatingDate: Date? /// The state of wether reactions listed on the timeline are expanded/collapsed. /// Key is itemID, value is the collapsed state. @@ -247,6 +248,7 @@ struct TimelineState { /// These can be removed when we have full swiftUI and moved as @State values in the view var scrollToBottomPublisher = PassthroughSubject() + var scrollToFirstItemForDatePublisher = PassthroughSubject() var itemsDictionary = OrderedDictionary() diff --git a/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift b/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift index fd46ad3de..82f0189e3 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift @@ -145,7 +145,7 @@ class TimelineTableViewController: UIViewController { } @Binding private var isScrolledToBottom: Bool - @Binding private var floatingDateText: String? + @Binding private var floatingDate: Date? /// A work item used to auto-hide the floating date badge after scrolling stops. private var floatingDateHideWorkItem: DispatchWorkItem? @@ -179,11 +179,12 @@ class TimelineTableViewController: UIViewController { init(coordinator: TimelineViewRepresentable.Coordinator, isScrolledToBottom: Binding, - floatingDateText: Binding, - scrollToBottomPublisher: PassthroughSubject) { + floatingDate: Binding, + scrollToBottomPublisher: PassthroughSubject, + scrollToFirstItemForDatePublisher: PassthroughSubject) { self.coordinator = coordinator _isScrolledToBottom = isScrolledToBottom - _floatingDateText = floatingDateText + _floatingDate = floatingDate super.init(nibName: nil, bundle: nil) @@ -209,6 +210,12 @@ class TimelineTableViewController: UIViewController { } .store(in: &cancellables) + scrollToFirstItemForDatePublisher + .sink { [weak self] _ in + self?.scrollToFirstItemForCurrentDate() + } + .store(in: &cancellables) + paginatePublisher .collect(.byTime(DispatchQueue.main, 0.1)) .sink { [weak self] _ in @@ -518,10 +525,10 @@ extension TimelineTableViewController: UITableViewDelegate { // MARK: - Floating Date Badge extension TimelineTableViewController { - /// Computes the formatted date text for the topmost visible timeline item + /// Computes the timestamp for the topmost visible timeline item /// and updates the floating date binding. func updateFloatingDate() { - guard let dateText = newestVisibleDateText() else { + guard let date = newestVisibleDate() else { return } @@ -530,9 +537,9 @@ extension TimelineTableViewController { // to extend the display duration of the floating date. scheduleFloatingDateHide() - // Only update on changes to avoid needless SwiftUI recomputation. - if floatingDateText != dateText { - floatingDateText = dateText + // Only update when the calendar day changes to avoid needless SwiftUI recomputation. + if floatingDate.map({ !Calendar.current.isDate($0, inSameDayAs: date) }) ?? true { + floatingDate = date } } @@ -540,18 +547,33 @@ extension TimelineTableViewController { func scheduleFloatingDateHide() { floatingDateHideWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in - self?.floatingDateText = nil + self?.floatingDate = nil } floatingDateHideWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) } - /// Returns the formatted date text for the newest visible timeline item. + /// Scrolls to the first (oldest) item on the same calendar day as the current floating date. + private func scrollToFirstItemForCurrentDate() { + guard let floatingDate else { return } + // timelineItemsDictionary is ordered oldest-first; the first match is the earliest item for that day. + for uniqueID in timelineItemsDictionary.keys { + if let timestamp = timelineItemsDictionary[uniqueID]?.timestamp, + Calendar.current.isDate(timestamp, inSameDayAs: floatingDate), + let indexPath = dataSource?.indexPath(for: uniqueID) { + // The table view is flipped, so .bottom aligns the cell to the visual top. + tableView.scrollToRow(at: indexPath, at: .bottom, animated: true) + return + } + } + } + + /// Returns the timestamp of the newest visible timeline item. /// /// The table view is flipped (unless VoiceOver is running), so the "newest" /// visible cell on screen is actually the *last* index path in `indexPathsForVisibleRows` /// when the table is flipped, or the *first* when it is not. - private func newestVisibleDateText() -> String? { + private func newestVisibleDate() -> Date? { guard let visibleIndexPaths = tableView.indexPathsForVisibleRows, !visibleIndexPaths.isEmpty else { return nil @@ -562,11 +584,11 @@ extension TimelineTableViewController { let isFlipped = scaleY == -1 let orderedPaths = isFlipped ? visibleIndexPaths.reversed() : visibleIndexPaths - // Walk from topmost downward and return the date of the first item that has one. + // Walk from topmost downward and return the timestamp of the first item that has one. for indexPath in orderedPaths { if let uniqueID = dataSource?.itemIdentifier(for: indexPath), let timestamp = timelineItemsDictionary[uniqueID]?.timestamp { - return timestamp.formattedDateSeparator() + return timestamp } } diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 10ebe5f4f..6413c4377 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -178,6 +178,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { paginateForwards() case .scrollToBottom: scrollToBottom() + case .scrollToFirstItemForCurrentDate: + state.timelineState.scrollToFirstItemForDatePublisher.send() case .displayTimelineItemMenu(let itemID): timelineInteractionHandler.displayTimelineItemActionMenu(for: itemID) case .handleTimelineItemMenuAction(let itemID, let action): diff --git a/ElementX/Sources/Screens/Timeline/View/FloatingDateBadge.swift b/ElementX/Sources/Screens/Timeline/View/FloatingDateBadge.swift index 624c677b0..92f80b33f 100644 --- a/ElementX/Sources/Screens/Timeline/View/FloatingDateBadge.swift +++ b/ElementX/Sources/Screens/Timeline/View/FloatingDateBadge.swift @@ -13,6 +13,7 @@ import SwiftUI /// fades out shortly after scrolling stops. struct FloatingDateBadge: View { let dateText: String? + var onTap: (() -> Void)? @Environment(\.colorScheme) private var colorScheme: ColorScheme @@ -28,14 +29,18 @@ struct FloatingDateBadge: View { var body: some View { ZStack { if let dateText { - Text(dateText) - .font(.compound.bodySMSemibold) - .foregroundColor(.compound.textPrimary) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(backgroundColor, in: .capsule) - .shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4) - .transition(.opacity) + Button { onTap?() } label: { + Text(dateText) + .font(.compound.bodySMSemibold) + .foregroundColor(.compound.textPrimary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(backgroundColor, in: .capsule) + .shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4) + } + .buttonStyle(.plain) + .disabled(onTap == nil) + .transition(.opacity) } } .animation(.easeInOut(duration: 0.15).disabledDuringTests(), value: dateText) diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift index 131b16ffa..7deffb181 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift @@ -83,8 +83,9 @@ struct TimelineViewRepresentable: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> TimelineTableViewController { TimelineTableViewController(coordinator: context.coordinator, isScrolledToBottom: $viewModelContext.isScrolledToBottom, - floatingDateText: $viewModelContext.floatingDateText, - scrollToBottomPublisher: viewModelContext.viewState.timelineState.scrollToBottomPublisher) + floatingDate: $viewModelContext.floatingDate, + scrollToBottomPublisher: viewModelContext.viewState.timelineState.scrollToBottomPublisher, + scrollToFirstItemForDatePublisher: viewModelContext.viewState.timelineState.scrollToFirstItemForDatePublisher) } func updateUIViewController(_ uiViewController: TimelineTableViewController, context: Context) { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-en-GB-0.png index baff04365..9699cb8e2 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f1fe8beb8c258e07ceff2aef6a7066c35aa2fbf23dc8de8c01166505554f1859 -size 50048 +oid sha256:d508b5192fd7e8a860e4255a8587160d49e39c0eacb276409949f4d7d41e4b85 +size 42801 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-pseudo-0.png index baff04365..9699cb8e2 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f1fe8beb8c258e07ceff2aef6a7066c35aa2fbf23dc8de8c01166505554f1859 -size 50048 +oid sha256:d508b5192fd7e8a860e4255a8587160d49e39c0eacb276409949f4d7d41e4b85 +size 42801 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-en-GB-0.png index 1821ddb95..5875ae714 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dde984085f2c4ebde7a482721ff1eff6f3c5d13c9e6a853756e150a2b3341384 -size 38360 +oid sha256:8d3dfac7d6e711936a35fd179f0631aea2bf4e8d7587f467fea06893c272c7ec +size 31011 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-pseudo-0.png index 1821ddb95..5875ae714 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dde984085f2c4ebde7a482721ff1eff6f3c5d13c9e6a853756e150a2b3341384 -size 38360 +oid sha256:8d3dfac7d6e711936a35fd179f0631aea2bf4e8d7587f467fea06893c272c7ec +size 31011