Add an action to the newly introduced FloatingDateBadge that scrolls the timeline to that day. (#5350)

and replace the TimelnieTableViewController's `floatingDateText` with a pure date.
This commit is contained in:
Stefan Ceriu
2026-04-14 11:27:27 +03:00
committed by GitHub
parent 07b139b307
commit 7ff2cf618b
11 changed files with 73 additions and 37 deletions
@@ -110,7 +110,9 @@ struct RoomScreen: View {
@ViewBuilder @ViewBuilder
private var dateBadge: some View { private var dateBadge: some View {
if !isVoiceOverEnabled { if !isVoiceOverEnabled {
FloatingDateBadge(dateText: timelineContext.floatingDateText) FloatingDateBadge(dateText: timelineContext.floatingDate?.formattedDateSeparator()) {
timelineContext.send(viewAction: .scrollToFirstItemForCurrentDate)
}
} }
} }
@@ -32,8 +32,10 @@ struct ThreadTimelineScreen: View {
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background. .toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel) .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
.overlay(alignment: .top) { .overlay(alignment: .top) {
FloatingDateBadge(dateText: timelineContext.floatingDateText) FloatingDateBadge(dateText: timelineContext.floatingDate?.formattedDateSeparator()) {
.padding(.top, 13) timelineContext.send(viewAction: .scrollToFirstItemForCurrentDate)
}
.padding(.top, 13)
} }
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
TimelineScrollToBottomButton(isVisible: isAtBottomAndLive) { TimelineScrollToBottomButton(isVisible: isAtBottomAndLive) {
@@ -56,6 +56,7 @@ enum TimelineViewAction {
case paginateBackwards case paginateBackwards
case paginateForwards case paginateForwards
case scrollToBottom case scrollToBottom
case scrollToFirstItemForCurrentDate
case displayTimelineItemMenu(itemID: TimelineItemIdentifier) case displayTimelineItemMenu(itemID: TimelineItemIdentifier)
case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction) case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)
@@ -152,8 +153,8 @@ struct TimelineViewState: BindableState {
struct TimelineViewStateBindings { struct TimelineViewStateBindings {
var isScrolledToBottom = true var isScrolledToBottom = true
/// The formatted date text for the floating date badge shown while scrolling. /// The timestamp of the topmost visible item, used to drive the floating date badge while scrolling.
var floatingDateText: String? var floatingDate: Date?
/// The state of wether reactions listed on the timeline are expanded/collapsed. /// The state of wether reactions listed on the timeline are expanded/collapsed.
/// Key is itemID, value is the collapsed state. /// 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 /// These can be removed when we have full swiftUI and moved as @State values in the view
var scrollToBottomPublisher = PassthroughSubject<Void, Never>() var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
var scrollToFirstItemForDatePublisher = PassthroughSubject<Void, Never>()
var itemsDictionary = OrderedDictionary<TimelineItemIdentifier.UniqueID, RoomTimelineItemViewState>() var itemsDictionary = OrderedDictionary<TimelineItemIdentifier.UniqueID, RoomTimelineItemViewState>()
@@ -145,7 +145,7 @@ class TimelineTableViewController: UIViewController {
} }
@Binding private var isScrolledToBottom: Bool @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. /// A work item used to auto-hide the floating date badge after scrolling stops.
private var floatingDateHideWorkItem: DispatchWorkItem? private var floatingDateHideWorkItem: DispatchWorkItem?
@@ -179,11 +179,12 @@ class TimelineTableViewController: UIViewController {
init(coordinator: TimelineViewRepresentable.Coordinator, init(coordinator: TimelineViewRepresentable.Coordinator,
isScrolledToBottom: Binding<Bool>, isScrolledToBottom: Binding<Bool>,
floatingDateText: Binding<String?>, floatingDate: Binding<Date?>,
scrollToBottomPublisher: PassthroughSubject<Void, Never>) { scrollToBottomPublisher: PassthroughSubject<Void, Never>,
scrollToFirstItemForDatePublisher: PassthroughSubject<Void, Never>) {
self.coordinator = coordinator self.coordinator = coordinator
_isScrolledToBottom = isScrolledToBottom _isScrolledToBottom = isScrolledToBottom
_floatingDateText = floatingDateText _floatingDate = floatingDate
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@@ -209,6 +210,12 @@ class TimelineTableViewController: UIViewController {
} }
.store(in: &cancellables) .store(in: &cancellables)
scrollToFirstItemForDatePublisher
.sink { [weak self] _ in
self?.scrollToFirstItemForCurrentDate()
}
.store(in: &cancellables)
paginatePublisher paginatePublisher
.collect(.byTime(DispatchQueue.main, 0.1)) .collect(.byTime(DispatchQueue.main, 0.1))
.sink { [weak self] _ in .sink { [weak self] _ in
@@ -518,10 +525,10 @@ extension TimelineTableViewController: UITableViewDelegate {
// MARK: - Floating Date Badge // MARK: - Floating Date Badge
extension TimelineTableViewController { 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. /// and updates the floating date binding.
func updateFloatingDate() { func updateFloatingDate() {
guard let dateText = newestVisibleDateText() else { guard let date = newestVisibleDate() else {
return return
} }
@@ -530,9 +537,9 @@ extension TimelineTableViewController {
// to extend the display duration of the floating date. // to extend the display duration of the floating date.
scheduleFloatingDateHide() scheduleFloatingDateHide()
// Only update on changes to avoid needless SwiftUI recomputation. // Only update when the calendar day changes to avoid needless SwiftUI recomputation.
if floatingDateText != dateText { if floatingDate.map({ !Calendar.current.isDate($0, inSameDayAs: date) }) ?? true {
floatingDateText = dateText floatingDate = date
} }
} }
@@ -540,18 +547,33 @@ extension TimelineTableViewController {
func scheduleFloatingDateHide() { func scheduleFloatingDateHide() {
floatingDateHideWorkItem?.cancel() floatingDateHideWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in let workItem = DispatchWorkItem { [weak self] in
self?.floatingDateText = nil self?.floatingDate = nil
} }
floatingDateHideWorkItem = workItem floatingDateHideWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: 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" /// The table view is flipped (unless VoiceOver is running), so the "newest"
/// visible cell on screen is actually the *last* index path in `indexPathsForVisibleRows` /// visible cell on screen is actually the *last* index path in `indexPathsForVisibleRows`
/// when the table is flipped, or the *first* when it is not. /// 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, guard let visibleIndexPaths = tableView.indexPathsForVisibleRows,
!visibleIndexPaths.isEmpty else { !visibleIndexPaths.isEmpty else {
return nil return nil
@@ -562,11 +584,11 @@ extension TimelineTableViewController {
let isFlipped = scaleY == -1 let isFlipped = scaleY == -1
let orderedPaths = isFlipped ? visibleIndexPaths.reversed() : visibleIndexPaths 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 { for indexPath in orderedPaths {
if let uniqueID = dataSource?.itemIdentifier(for: indexPath), if let uniqueID = dataSource?.itemIdentifier(for: indexPath),
let timestamp = timelineItemsDictionary[uniqueID]?.timestamp { let timestamp = timelineItemsDictionary[uniqueID]?.timestamp {
return timestamp.formattedDateSeparator() return timestamp
} }
} }
@@ -178,6 +178,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
paginateForwards() paginateForwards()
case .scrollToBottom: case .scrollToBottom:
scrollToBottom() scrollToBottom()
case .scrollToFirstItemForCurrentDate:
state.timelineState.scrollToFirstItemForDatePublisher.send()
case .displayTimelineItemMenu(let itemID): case .displayTimelineItemMenu(let itemID):
timelineInteractionHandler.displayTimelineItemActionMenu(for: itemID) timelineInteractionHandler.displayTimelineItemActionMenu(for: itemID)
case .handleTimelineItemMenuAction(let itemID, let action): case .handleTimelineItemMenuAction(let itemID, let action):
@@ -13,6 +13,7 @@ import SwiftUI
/// fades out shortly after scrolling stops. /// fades out shortly after scrolling stops.
struct FloatingDateBadge: View { struct FloatingDateBadge: View {
let dateText: String? let dateText: String?
var onTap: (() -> Void)?
@Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.colorScheme) private var colorScheme: ColorScheme
@@ -28,14 +29,18 @@ struct FloatingDateBadge: View {
var body: some View { var body: some View {
ZStack { ZStack {
if let dateText { if let dateText {
Text(dateText) Button { onTap?() } label: {
.font(.compound.bodySMSemibold) Text(dateText)
.foregroundColor(.compound.textPrimary) .font(.compound.bodySMSemibold)
.padding(.horizontal, 16) .foregroundColor(.compound.textPrimary)
.padding(.vertical, 8) .padding(.horizontal, 16)
.background(backgroundColor, in: .capsule) .padding(.vertical, 8)
.shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4) .background(backgroundColor, in: .capsule)
.transition(.opacity) .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) .animation(.easeInOut(duration: 0.15).disabledDuringTests(), value: dateText)
@@ -83,8 +83,9 @@ struct TimelineViewRepresentable: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> TimelineTableViewController { func makeUIViewController(context: Context) -> TimelineTableViewController {
TimelineTableViewController(coordinator: context.coordinator, TimelineTableViewController(coordinator: context.coordinator,
isScrolledToBottom: $viewModelContext.isScrolledToBottom, isScrolledToBottom: $viewModelContext.isScrolledToBottom,
floatingDateText: $viewModelContext.floatingDateText, floatingDate: $viewModelContext.floatingDate,
scrollToBottomPublisher: viewModelContext.viewState.timelineState.scrollToBottomPublisher) scrollToBottomPublisher: viewModelContext.viewState.timelineState.scrollToBottomPublisher,
scrollToFirstItemForDatePublisher: viewModelContext.viewState.timelineState.scrollToFirstItemForDatePublisher)
} }
func updateUIViewController(_ uiViewController: TimelineTableViewController, context: Context) { func updateUIViewController(_ uiViewController: TimelineTableViewController, context: Context) {
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f1fe8beb8c258e07ceff2aef6a7066c35aa2fbf23dc8de8c01166505554f1859 oid sha256:d508b5192fd7e8a860e4255a8587160d49e39c0eacb276409949f4d7d41e4b85
size 50048 size 42801
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f1fe8beb8c258e07ceff2aef6a7066c35aa2fbf23dc8de8c01166505554f1859 oid sha256:d508b5192fd7e8a860e4255a8587160d49e39c0eacb276409949f4d7d41e4b85
size 50048 size 42801
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dde984085f2c4ebde7a482721ff1eff6f3c5d13c9e6a853756e150a2b3341384 oid sha256:8d3dfac7d6e711936a35fd179f0631aea2bf4e8d7587f467fea06893c272c7ec
size 38360 size 31011
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dde984085f2c4ebde7a482721ff1eff6f3c5d13c9e6a853756e150a2b3341384 oid sha256:8d3dfac7d6e711936a35fd179f0631aea2bf4e8d7587f467fea06893c272c7ec
size 38360 size 31011