diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 80d911742..16f542886 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -164,6 +164,7 @@ 1A83DD22F3E6F76B13B6E2F9 /* VideoRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C8616254EE40CA8BA5E9BC2 /* VideoRoomTimelineItemContent.swift */; }; 1AB3D8563AB12635250A6A6E /* StaticLocationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15E0017717EAE3A1D02D005 /* StaticLocationScreenCoordinator.swift */; }; 1AE4AEA0FA8DEF52671832E0 /* RoomTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */; }; + 1AF4A82B4332CAD2DB3EB5DA /* TopBannerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78483F0143E185EDC6ECD741 /* TopBannerModifier.swift */; }; 1B2DADC008EE211AF1DA5292 /* NotificationManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30ED584467DB380E3CEFB1DB /* NotificationManagerTests.swift */; }; 1B2F9F368619FFF8C63C87CC /* BugReportScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EECE8B331CD169790EF284F /* BugReportScreenViewModelTests.swift */; }; 1B5B30839656AE2F957C6B1E /* test_pdf.pdf in Resources */ = {isa = PBXBuildFile; fileRef = BE98688578F8B0541D853695 /* test_pdf.pdf */; }; @@ -2115,6 +2116,7 @@ 7720ACAC6155AB7F9C70B546 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nb; path = nb.lproj/Localizable.stringsdict; sourceTree = ""; }; 7773CBFDBD458E0B7E270507 /* PillView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillView.swift; sourceTree = ""; }; 780258F1B9D15E30549FF4BE /* NotificationSettingsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModel.swift; sourceTree = ""; }; + 78483F0143E185EDC6ECD741 /* TopBannerModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopBannerModifier.swift; sourceTree = ""; }; 787E84119E626E2F0E0BFBE8 /* ManageRoomMemberSheetModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageRoomMemberSheetModels.swift; sourceTree = ""; }; 78910787F967CBC6042A101E /* StartChatScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenViewModelProtocol.swift; sourceTree = ""; }; 78913D6E120D46138E97C107 /* NavigationSplitCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSplitCoordinatorTests.swift; sourceTree = ""; }; @@ -3599,6 +3601,7 @@ A8558D41DD4B553A752C868A /* StackedAvatarsView.swift */, E10765FBC83B34A3BC4ADB23 /* TimelineScrollToBottomButton.swift */, 16D353E10A64172D863769BF /* TombstonedAvatarImage.swift */, + 78483F0143E185EDC6ECD741 /* TopBannerModifier.swift */, E10DA51DBC8C7E1460DBCCBD /* UserProfileListRow.swift */, AD529C89924EE32CE307F36F /* VisualListItem.swift */, ); @@ -8424,6 +8427,7 @@ 98EE4259A4A49BC757BA442C /* TimelineViewModel.swift in Sources */, F8B2F5CBCF2A0E0798E8D646 /* TimelineViewModelProtocol.swift in Sources */, B3D8AA9988F8A000B162DCB5 /* TombstonedAvatarImage.swift in Sources */, + 1AF4A82B4332CAD2DB3EB5DA /* TopBannerModifier.swift in Sources */, 6E44638FDF7D4B0F80EFA7EA /* TraceLogPack.swift in Sources */, 126CBCF5B0145FA1377C1316 /* Tracing.swift in Sources */, E010DDE938032D3B8E84CC35 /* TracingHook.swift in Sources */, diff --git a/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift b/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift new file mode 100644 index 000000000..18af8d224 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Views/TopBannerModifier.swift @@ -0,0 +1,27 @@ +// +// 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 SwiftUI + +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() + } + } + .animation(.elementDefault, value: isVisible) + .clipped() + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 0960d3595..1fdb61400 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -33,20 +33,15 @@ struct RoomScreen: View { .accessibilityIdentifier(A11yIdentifiers.roomScreen.scrollToBottom) } .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) - .overlay(alignment: .top) { - if !isVoiceOverEnabled { - pinnedItemsBanner - } - } + .topBanner(pinnedItemsBanner, isVisible: context.viewState.shouldShowPinnedEventsBanner && !isVoiceOverEnabled) // This can overlay on top of the pinnedItemsBanner - .overlay(alignment: .top) { - knockRequestsBanner - } + .topBanner(knockRequestsBanner, isVisible: context.viewState.shouldSeeKnockRequests) .safeAreaInset(edge: .top) { - // When voice over is on the table view is not reversed - // and the scroll gestures are not intercepted - // so we render the pinned banner on top. - if isVoiceOverEnabled { + // 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 + // above permanently obscures the top of the timeline. So whenever VoiceOver is + // enabled we use a safe area inset to vertically stack it above the timeline. + if context.viewState.shouldShowPinnedEventsBanner, isVoiceOverEnabled { pinnedItemsBanner } } @@ -79,41 +74,19 @@ struct RoomScreen: View { @ViewBuilder private var pinnedItemsBanner: some View { - // Color.clear and clipped() are required for iOS 26 transparent nav bar - VStack(spacing: 0) { - if context.viewState.shouldShowPinnedEventsBanner { - PinnedItemsBannerView(state: context.viewState.pinnedEventsBannerState, - onMainButtonTap: { context.send(viewAction: .tappedPinnedEventsBanner) }, - onViewAllButtonTap: { context.send(viewAction: .viewAllPins) }) - .transition(.move(edge: .top)) - } else { - Color.clear - .allowsHitTesting(false) - } - } - .animation(.elementDefault, value: context.viewState.shouldShowPinnedEventsBanner) - .clipped() + PinnedItemsBannerView(state: context.viewState.pinnedEventsBannerState, + onMainButtonTap: { context.send(viewAction: .tappedPinnedEventsBanner) }, + onViewAllButtonTap: { context.send(viewAction: .viewAllPins) }) } @ViewBuilder private var knockRequestsBanner: some View { - // Color.clear and clipped() are required for iOS 26 transparent nav bar - VStack(spacing: 0) { - if context.viewState.shouldSeeKnockRequests { - KnockRequestsBannerView(requests: context.viewState.displayedKnockRequests, - onDismiss: dismissKnockRequestsBanner, - onAccept: context.viewState.canAcceptKnocks ? acceptKnockRequest : nil, - onViewAll: onViewAllKnockRequests, - mediaProvider: context.mediaProvider) - .padding(.top, 16) - .transition(.move(edge: .top)) - } else { - Color.clear - .allowsHitTesting(false) - } - } - .animation(.elementDefault, value: context.viewState.shouldSeeKnockRequests) - .clipped() + KnockRequestsBannerView(requests: context.viewState.displayedKnockRequests, + onDismiss: dismissKnockRequestsBanner, + onAccept: context.viewState.canAcceptKnocks ? acceptKnockRequest : nil, + onViewAll: onViewAllKnockRequests, + mediaProvider: context.mediaProvider) + .padding(.top, 16) } private func dismissKnockRequestsBanner() {