// // Copyright 2025 Element Creations Ltd. // Copyright 2023-2025 New Vector 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 Foundation import SwiftUI import SwiftUIIntrospect extension View { /// Adds a bloom behind the navigation bar. /// - Parameter hasSearchBar: Whether or not the navigation bar contains a search bar (so that /// the bloom can be sized appropriately). @ViewBuilder func toolbarBloom(hasSearchBar: Bool) -> some View { if #available(iOS 26, *) { modifier(BloomModifier(hasSearchBar: hasSearchBar)) } else { modifier(OldBloomModifier(hasSearchBar: hasSearchBar)) } } } private struct BloomModifier: ViewModifier { let hasSearchBar: Bool @State private var height = CGFloat.zero private var endPointY: CGFloat { hasSearchBar ? 0.35 : 0.55 } func body(content: Content) -> some View { content .onGeometryChange(for: CGFloat.self) { proxy in proxy.safeAreaInsets.top } action: { height in self.height = height } .overlay(alignment: .top) { LinearGradient(gradient: .compound.subtle, startPoint: .top, endPoint: .init(x: 0.5, y: endPointY)) .ignoresSafeArea(edges: .all) .frame(height: height) .allowsHitTesting(false) // Does not render properly on dark themes otherwise .colorScheme(.light) } } } private struct OldBloomModifier: ViewModifier { @Environment(\.colorScheme) private var colorScheme let hasSearchBar: Bool @State private var standardAppearance = UINavigationBarAppearance() @State private var scrollEdgeAppearance = UINavigationBarAppearance() @State private var bloom = Bloom() func body(content: Content) -> some View { content .introspect(.viewController, on: .supportedVersions, customize: configureBloom) } private func configureBloom(controller: UIViewController) { if controller.navigationItem.standardAppearance == standardAppearance, controller.navigationItem.scrollEdgeAppearance == scrollEdgeAppearance, canUse(bloom) { return } let bloom = makeBloom() standardAppearance.configureWithDefaultBackground() standardAppearance.backgroundImage = bloom.image standardAppearance.backgroundImageContentMode = .scaleToFill controller.navigationItem.standardAppearance = standardAppearance scrollEdgeAppearance.configureWithTransparentBackground() scrollEdgeAppearance.backgroundImage = bloom.image scrollEdgeAppearance.backgroundImageContentMode = .scaleToFill scrollEdgeAppearance.backgroundColor = .compound.bgCanvasDefault controller.navigationItem.scrollEdgeAppearance = scrollEdgeAppearance } private func makeBloom() -> Bloom { if bloom.image != nil, canUse(bloom) { return bloom } // There's a bug somewhere when rendering in dark mode (which we've mistakenly not been doing) // which results in the first 5 stops not having any alpha, only the last one… let newImage = ImageRenderer(content: bloomGradient /* .colorScheme(colorScheme) */ ).uiImage bloom.image = newImage bloom.colorScheme = colorScheme bloom.baseColor = .compound.gradientSubtleStop1 return bloom } private var endPointY: CGFloat { hasSearchBar ? 0.5 : 0.7 } private var bloomGradient: some View { LinearGradient(gradient: .compound.subtle, startPoint: .top, endPoint: .init(x: 0.5, y: endPointY)) .ignoresSafeArea(edges: .all) .frame(width: 256, height: 256) } private func canUse(_ bloom: Bloom) -> Bool { // Don't check for a nil image in here, there's no point re-rendering over and over if the render fails. bloom.colorScheme == colorScheme && bloom.baseColor == .compound.gradientSubtleStop1 } /// This is a class to avoid a "Modifying state during view update" warning when storing /// the result on the same run-loop - we want to avoid dispatching that to the next loop as /// that can result in further (unnecessary) renders being made. class Bloom { var image: UIImage? var colorScheme: ColorScheme? var baseColor: Color? } } // MARK: - Previews struct BloomModifier_Previews: PreviewProvider, TestablePreview { static var previews: some View { ElementNavigationStack { mockScreen .navigationTitle(L10n.screenRoomlistMainSpaceTitle) .searchable(text: .constant(""), placement: .navigationBarDrawer(displayMode: .always)) .toolbarBloom(hasSearchBar: true) } .previewDisplayName("Chats") ElementNavigationStack { mockScreen .navigationTitle(L10n.screenSpaceListTitle) .toolbarBloom(hasSearchBar: false) } .previewDisplayName("Spaces") } static var mockScreen: some View { List { } .toolbar { Button { } label: { CompoundIcon(\.check) } .accessibilityLabel(L10n.actionConfirm) // Keep the a11y tests happy 😄 } } }