From 7296b09a17b4d3ad26d42e1765249d5d77786478 Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Fri, 27 Mar 2026 17:19:33 +0100 Subject: [PATCH] implemented the UI to render the formatted floating date --- .../Sources/GeneratedAccessibilityTests.swift | 4 ++ ElementX.xcodeproj/project.pbxproj | 4 ++ .../SwiftUI/Views/TopBannerModifier.swift | 51 +++++++++++++--- .../TestablePreviewsDictionary.swift | 1 + .../Screens/RoomScreen/View/RoomScreen.swift | 15 ++++- .../View/ThreadTimelineScreen.swift | 6 ++ .../Timeline/View/FloatingDateBadge.swift | 58 +++++++++++++++++++ .../Screens/Timeline/View/TimelineView.swift | 1 + .../Sources/GeneratedPreviewTests.swift | 8 +++ .../floatingDateBadge.iPad-en-GB-0.png | 3 + .../floatingDateBadge.iPad-pseudo-0.png | 3 + .../floatingDateBadge.iPhone-en-GB-0.png | 3 + .../floatingDateBadge.iPhone-pseudo-0.png | 3 + 13 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 ElementX/Sources/Screens/Timeline/View/FloatingDateBadge.swift create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-pseudo-0.png diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 793a9f0a0..56d0cbd29 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -159,6 +159,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "FileRoomTimelineView_Previews") } + func testFloatingDateBadge() async throws { + try await performAccessibilityAudit(named: "FloatingDateBadge_Previews") + } + func testFormButtonStyles() async throws { try await performAccessibilityAudit(named: "FormButtonStyles_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 5da1edbcf..835136ff8 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 0180C44B997EDA8D21F883AC /* RoomNotificationSettingsCustomSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B746EFA112532A7B701FB914 /* RoomNotificationSettingsCustomSectionView.swift */; }; 01B63F1A04A276B39AC17014 /* CallInviteRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9A3D3CFA199FA7897364547 /* CallInviteRoomTimelineItem.swift */; }; 020F7E70167FB2833266F2F0 /* AnalyticsSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D39D7F513A36C9C1951DB44C /* AnalyticsSettingsScreen.swift */; }; + 023E44445795A279281E6106 /* FloatingDateBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECEC9A622AA2F8DBE928AC78 /* FloatingDateBadge.swift */; }; 024E70451A7CD9E4E034D8A9 /* VoiceMessageRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D529B976F8B2AA654D923422 /* VoiceMessageRoomTimelineItem.swift */; }; 02A92F8F4538CECDFB4F2607 /* RoomDirectorySearchScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1562EAF6231151A675BED7A9 /* RoomDirectorySearchScreenCoordinator.swift */; }; 02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 218AB05B4E3889731959C5F1 /* EventBasedTimelineItemProtocol.swift */; }; @@ -2867,6 +2868,7 @@ ECB836DD8BE31931F51B8AC9 /* EncryptionSettingsFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionSettingsFlowCoordinator.swift; sourceTree = ""; }; ECD5FCBA169B6A82F501CA1B /* AnalyticsSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenViewModelProtocol.swift; sourceTree = ""; }; ECE03E834CC8C2721899E6AC /* StaticLocationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticLocationSheet.swift; sourceTree = ""; }; + ECEC9A622AA2F8DBE928AC78 /* FloatingDateBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingDateBadge.swift; sourceTree = ""; }; ECF79FB25E2D4BD6F50CE7C9 /* RoomMembersListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenViewModel.swift; sourceTree = ""; }; ECFB21A2FF41CF0E118790DD /* StaticLocationData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticLocationData.swift; sourceTree = ""; }; ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = ""; }; @@ -6877,6 +6879,7 @@ FDF04D0E125CB4B5C5DB5191 /* View */ = { isa = PBXGroup; children = ( + ECEC9A622AA2F8DBE928AC78 /* FloatingDateBadge.swift */, BCDA016D05107DED3B9495CB /* TimelineItemDebugView.swift */, D53FCCE44F96E0BC411A6CF0 /* TimelineSenderAvatarView.swift */, 93C713D124FE915ABF47A6B7 /* TimelineView.swift */, @@ -8171,6 +8174,7 @@ D33AC79A50DFC26D2498DD28 /* FileRoomTimelineItem.swift in Sources */, 37D789F24199B32E3FD1AA7B /* FileRoomTimelineItemContent.swift in Sources */, 64EE9D2CF7AD02EE53983CE1 /* FileRoomTimelineView.swift in Sources */, + 023E44445795A279281E6106 /* FloatingDateBadge.swift in Sources */, F5D2270B5021D521C0D22E11 /* FlowCoordinatorProtocol.swift in Sources */, 18E3786918486D4C9726BC84 /* FormButtonStyles.swift in Sources */, 4D0F4385B7DDB68C66C78857 /* FormattedBodyText.swift in Sources */, diff --git a/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift b/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift index 18af8d224..c9313837f 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift @@ -7,20 +7,53 @@ import SwiftUI +/// A single banner item to be displayed in a `topBanners` overlay. +struct TopBannerItem { + var banner: AnyView + var isVisible: Bool + + init(_ banner: some View, isVisible: Bool) { + self.banner = AnyView(banner) + self.isVisible = isVisible + } +} + extension View { /// Overlays the given banner view at the top edge of this view, using a /// slide from the top edge when `isVisible` is toggled. - func topBanner(_ banner: some View, isVisible: Bool) -> some View { - overlay(alignment: .top) { - ZStack { - if isVisible { - banner.transition(.move(edge: .top)) - } else { - // An equal amount of space needs to be reserved in order for the transition to work. - banner.hidden() + func topBanner(_ banner: some View, isVisible: Bool, footer: some View = EmptyView()) -> some View { + topBanners([TopBannerItem(banner, isVisible: isVisible)], footer: footer) + } + + /// Overlays the given banner views at the top edge of this view. Each banner + /// slides from the top edge based on its own `isVisible` flag. Later items in + /// the array are overlayed on top of earlier ones. The footer is shared across + /// all banners and displayed below the topmost visible banner. + func topBanners(_ items: [TopBannerItem], footer: some View = EmptyView()) -> some View { + let anyBannerVisible = items.contains { $0.isVisible } + return overlay(alignment: .top) { + ZStack(alignment: .top) { + // Visible layout + VStack(spacing: 0) { + ZStack(alignment: .top) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + if item.isVisible { + item.banner + .transition(.move(edge: .top)) + } + } + } + footer + // Banners include a 28 padding to include shadows in their size + // so we need to remove 28 if any is visible + .padding(.top, anyBannerVisible ? -15 : 13) } + // Hidden layout used for sizing when no banner is visible + Color.clear + .hidden() + .allowsHitTesting(false) } - .animation(.elementDefault, value: isVisible) + .animation(.elementDefault, value: items.map(\.isVisible)) .clipped() } } diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index d49a39c61..322950746 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -47,6 +47,7 @@ enum TestablePreviewsDictionary { "EstimatedWaveformView_Previews" : EstimatedWaveformView_Previews.self, "FileMediaEventsTimelineView_Previews" : FileMediaEventsTimelineView_Previews.self, "FileRoomTimelineView_Previews" : FileRoomTimelineView_Previews.self, + "FloatingDateBadge_Previews" : FloatingDateBadge_Previews.self, "FormButtonStyles_Previews" : FormButtonStyles_Previews.self, "FormattedBodyText_Previews" : FormattedBodyText_Previews.self, "FormattingToolbar_Previews" : FormattingToolbar_Previews.self, diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 93fbbadff..8373bd6e2 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -33,9 +33,11 @@ struct RoomScreen: View { .accessibilityIdentifier(A11yIdentifiers.roomScreen.scrollToBottom) } .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) - .topBanner(pinnedItemsBanner, isVisible: context.viewState.shouldShowPinnedEventsBanner && !isVoiceOverEnabled) - // This can overlay on top of the pinnedItemsBanner - .topBanner(knockRequestsBanner, isVisible: context.viewState.shouldSeeKnockRequests) + .topBanners([ + TopBannerItem(pinnedItemsBanner, isVisible: context.viewState.shouldShowPinnedEventsBanner && !isVoiceOverEnabled), + // This can overlay on top of the pinnedItemsBanner + TopBannerItem(knockRequestsBanner, isVisible: context.viewState.shouldSeeKnockRequests) + ], footer: dateBadge) .safeAreaInset(edge: .top) { // When VoiceOver is enabled, the table view isn't reversed and the scroll gestures // don't trigger meaning the banner never hides itself and so the .overlay layout @@ -88,6 +90,13 @@ struct RoomScreen: View { .padding(.top, 16) } + @ViewBuilder + private var dateBadge: some View { + if timelineContext.viewState.floatingTimelineDateEnabled, !isVoiceOverEnabled { + FloatingDateBadge(dateText: timelineContext.floatingDateText) + } + } + private func dismissKnockRequestsBanner() { context.send(viewAction: .dismissKnockRequests) } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift index ed7633f32..9874babae 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift @@ -31,6 +31,12 @@ struct ThreadTimelineScreen: View { .toolbar { toolbar } .toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background. .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel) + .overlay(alignment: .top) { + if timelineContext.viewState.floatingTimelineDateEnabled { + FloatingDateBadge(dateText: timelineContext.floatingDateText) + .padding(.top, 13) + } + } .overlay(alignment: .bottomTrailing) { TimelineScrollToBottomButton(isVisible: isAtBottomAndLive) { timelineContext.send(viewAction: .scrollToBottom) diff --git a/ElementX/Sources/Screens/Timeline/View/FloatingDateBadge.swift b/ElementX/Sources/Screens/Timeline/View/FloatingDateBadge.swift new file mode 100644 index 000000000..9b72fa6ab --- /dev/null +++ b/ElementX/Sources/Screens/Timeline/View/FloatingDateBadge.swift @@ -0,0 +1,58 @@ +// +// Copyright 2025 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import SwiftUI + +/// A floating badge that displays the date of the topmost visible timeline item +/// while the user is scrolling the timeline. It fades in when scrolling starts and +/// fades out shortly after scrolling stops. +struct FloatingDateBadge: View { + let dateText: String? + + @Environment(\.colorScheme) private var colorScheme: ColorScheme + + private var backgroundColor: Color { + switch colorScheme { + case .dark: + .compound.bgSubtlePrimary + default: + .compound.bgCanvasDefault + } + } + + var body: some View { + ZStack { + if let dateText { + Text(dateText) + .font(.compound.bodySMSemibold) + .foregroundColor(.compound.textPrimary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Capsule() + .fill(backgroundColor)) + .shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.15).disabledDuringTests(), value: dateText) + } +} + +// MARK: - Previews + +struct FloatingDateBadge_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + VStack(spacing: 20) { + FloatingDateBadge(dateText: "Today") + FloatingDateBadge(dateText: "Yesterday") + FloatingDateBadge(dateText: "Tuesday, January 9, 2007") + } + .padding() + .previewLayout(.sizeThatFits) + } +} diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift index 2acbbf71d..131b16ffa 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift @@ -83,6 +83,7 @@ struct TimelineViewRepresentable: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> TimelineTableViewController { TimelineTableViewController(coordinator: context.coordinator, isScrolledToBottom: $viewModelContext.isScrolledToBottom, + floatingDateText: $viewModelContext.floatingDateText, scrollToBottomPublisher: viewModelContext.viewState.timelineState.scrollToBottomPublisher) } diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 68033380a..86b4b5457 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -315,6 +315,14 @@ extension PreviewTests { } } + @Test + func floatingDateBadge() async throws { + AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings. + for (index, preview) in FloatingDateBadge_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func formButtonStyles() async throws { AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings. diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-en-GB-0.png new file mode 100644 index 000000000..baff04365 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1fe8beb8c258e07ceff2aef6a7066c35aa2fbf23dc8de8c01166505554f1859 +size 50048 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-pseudo-0.png new file mode 100644 index 000000000..baff04365 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1fe8beb8c258e07ceff2aef6a7066c35aa2fbf23dc8de8c01166505554f1859 +size 50048 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-en-GB-0.png new file mode 100644 index 000000000..1821ddb95 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dde984085f2c4ebde7a482721ff1eff6f3c5d13c9e6a853756e150a2b3341384 +size 38360 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-pseudo-0.png new file mode 100644 index 000000000..1821ddb95 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/floatingDateBadge.iPhone-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dde984085f2c4ebde7a482721ff1eff6f3c5d13c9e6a853756e150a2b3341384 +size 38360