Add the new bloom style under a feature flag. (#4033)

* Refactor the bloom into a modifier.

It currently depends on the home screen context but that will be removed in the future.

* Add an initial implementation of the new bloom using an image on the navigation item's appearance.

* Add a feature flag to control the new bloom.
This commit is contained in:
Doug
2025-04-16 16:36:19 +01:00
committed by GitHub
parent b23d92e2cf
commit 682f91a2ff
9 changed files with 234 additions and 143 deletions

View File

@@ -484,7 +484,6 @@
5DFC2A889D3B39DD47AC63A8 /* PillUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C537DE821FED94D23467B6C4 /* PillUtilities.swift */; };
5EC046E41755C095DAB1C3FF /* TimelineProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8C9BBB729C941BEE0E2A63 /* TimelineProviderProtocol.swift */; };
5EDBDE802761B5ECB54E6787 /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2711E5996016ABD6EAAEB58A /* LogLevel.swift */; };
5EE1D4E316D66943E97FDCF2 /* BloomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BEB970F500BFB248443FA1 /* BloomView.swift */; };
5F06AD3C66884CE793AE6119 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */; };
5F0B5797D1BFF2A51084B4C3 /* PinnedEventsTimelineScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86D7CD5CA270BFC3EBB450CA /* PinnedEventsTimelineScreenViewModel.swift */; };
5F35069E13D71DD88633A4B2 /* preview_video.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 45A4B934BA41D6C255900265 /* preview_video.jpg */; };
@@ -959,6 +958,7 @@
BB6BF528BC7F5B87E08C4F18 /* CameraPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A3B7637DDBD6AA97AC2545 /* CameraPicker.swift */; };
BB784A02BADB03C820617A46 /* TextRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90A55430639712CFACA34F43 /* TextRoomTimelineItem.swift */; };
BB9B800C6094E34860E89DC5 /* AppLockSetupBiometricsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CCF9A924521DECA44778C4 /* AppLockSetupBiometricsScreen.swift */; };
BC1222EDFF0C240F14259315 /* BloomModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D152423EE6CF0ECCC84091A /* BloomModifier.swift */; };
BC7CA1379D7C24F47B1B8B7E /* PaginationIndicatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7F7A975514E850A834B29F /* PaginationIndicatorRoomTimelineView.swift */; };
BCC864190651B3A3CF51E4DF /* MediaFileHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */; };
BD0BE20DBCE31253AE4490A1 /* RoomListFiltersEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1DDB2293A51EA4C2739351 /* RoomListFiltersEmptyStateView.swift */; };
@@ -2044,6 +2044,7 @@
8C44BBC892499BE45B074F89 /* AppLockScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenCoordinator.swift; sourceTree = "<group>"; };
8C8616254EE40CA8BA5E9BC2 /* VideoRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineItemContent.swift; sourceTree = "<group>"; };
8CC23C63849452BC86EA2852 /* ButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyle.swift; sourceTree = "<group>"; };
8D152423EE6CF0ECCC84091A /* BloomModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloomModifier.swift; sourceTree = "<group>"; };
8D1FA20DAB853C1156054912 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/InfoPlist.strings; sourceTree = "<group>"; };
8D55702474F279D910D2D162 /* RoomStateEventStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomStateEventStringBuilder.swift; sourceTree = "<group>"; };
8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -2415,7 +2416,6 @@
D79BB714D28C9F588DD69353 /* SecureBackupScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupScreenViewModelProtocol.swift; sourceTree = "<group>"; };
D7B18089ED50324583BB2FB7 /* EditRoomAddressScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditRoomAddressScreenViewModelProtocol.swift; sourceTree = "<group>"; };
D7BB243B26D54EF1A0C422C0 /* NotificationContentBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentBuilder.swift; sourceTree = "<group>"; };
D7BEB970F500BFB248443FA1 /* BloomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloomView.swift; sourceTree = "<group>"; };
D80807B554CF9C524F98674F /* ManageRoomMemberSheetViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageRoomMemberSheetViewModelTests.swift; sourceTree = "<group>"; };
D879DC5515B1D42577F96C94 /* RoomSelectionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSelectionScreen.swift; sourceTree = "<group>"; };
D8AA084E10B80D64449C02A9 /* SessionVerificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationTests.swift; sourceTree = "<group>"; };
@@ -3858,7 +3858,7 @@
4F43EBE458FBE634996AD7C6 /* View */ = {
isa = PBXGroup;
children = (
D7BEB970F500BFB248443FA1 /* BloomView.swift */,
8D152423EE6CF0ECCC84091A /* BloomModifier.swift */,
B902EA6CD3296B0E10EE432B /* HomeScreen.swift */,
A3B4B58B79A6FA250B24A1EC /* HomeScreenContent.swift */,
C0FEA560929DD73FFEF8C3DF /* HomeScreenEmptyStateView.swift */,
@@ -7029,7 +7029,7 @@
8C91D242BEEC657FABCC0B95 /* BlockedUsersScreenModels.swift in Sources */,
934051B17A884AB0635DF81B /* BlockedUsersScreenViewModel.swift in Sources */,
A4B123C635F70DDD4BC2FAC9 /* BlockedUsersScreenViewModelProtocol.swift in Sources */,
5EE1D4E316D66943E97FDCF2 /* BloomView.swift in Sources */,
BC1222EDFF0C240F14259315 /* BloomModifier.swift in Sources */,
54FDA3625AACBD9E438D084D /* BlurEffectView.swift in Sources */,
B6DF6B6FA8734B70F9BF261E /* BlurHashDecode.swift in Sources */,
E794AB6ABE1FF5AF0573FEA1 /* BlurHashEncode.swift in Sources */,

View File

@@ -48,6 +48,7 @@ final class AppSettings {
case hideUnreadMessagesBadge
case hideInviteAvatars
case timelineMediaVisibility
case isNewBloomEnabled
case elementCallBaseURLOverride
@@ -361,6 +362,9 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.timelineMediaVisibility, defaultValue: TimelineMediaVisibility.always, storageType: .userDefaults(store))
var timelineMediaVisibility
@UserPreference(key: UserDefaultsKeys.isNewBloomEnabled, defaultValue: false, storageType: .userDefaults(store))
var isNewBloomEnabled
}
extension AppSettings: CommonSettingsProtocol { }

View File

@@ -105,6 +105,10 @@ struct HomeScreenViewState: BindableState {
var reportRoomEnabled = false
// Intentionally not mutable so that we don't have to reset the navigation bar's
// appearance whenever the feature flag is toggled (requires a restart).
let isNewBloomEnabled: Bool
var visibleRooms: [HomeScreenRoom] {
if roomListMode == .skeletons {
return placeholderRooms

View File

@@ -37,7 +37,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
roomSummaryProvider = userSession.clientProxy.roomSummaryProvider
super.init(initialViewState: .init(userID: userSession.clientProxy.userID),
super.init(initialViewState: .init(userID: userSession.clientProxy.userID, isNewBloomEnabled: appSettings.isNewBloomEnabled),
mediaProvider: userSession.mediaProvider)
userSession.clientProxy.userAvatarURLPublisher

View File

@@ -0,0 +1,211 @@
//
// Copyright 2023, 2024 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 SwiftUI
import SwiftUIIntrospect
extension View {
// Note: The dependency on HomeScreenViewModel.Context will be removed in the next iteration.
@ViewBuilder
func bloom(context: HomeScreenViewModel.Context, scrollViewAdapter: ScrollViewAdapter, isNewBloomEnabled: Bool) -> some View {
if isNewBloomEnabled {
modifier(NewBloomModifier())
} else {
modifier(BloomModifier(context: context, scrollViewAdapter: scrollViewAdapter))
}
}
}
struct NewBloomModifier: ViewModifier {
@State private var standardAppearance = UINavigationBarAppearance()
@State private var scrollEdgeAppearance = UINavigationBarAppearance()
@State private var bloomGradientImage: UIImage?
func body(content: Content) -> some View {
content
.introspect(.viewController, on: .supportedVersions, customize: configureBloom)
}
private func configureBloom(controller: UIViewController) {
guard controller.navigationItem.standardAppearance != standardAppearance,
controller.navigationItem.scrollEdgeAppearance != scrollEdgeAppearance else {
return
}
let image = makeBloomImage()
standardAppearance.configureWithDefaultBackground()
standardAppearance.backgroundImage = image
standardAppearance.backgroundImageContentMode = .scaleToFill
controller.navigationItem.standardAppearance = standardAppearance
scrollEdgeAppearance.configureWithTransparentBackground()
scrollEdgeAppearance.backgroundImage = image
scrollEdgeAppearance.backgroundImageContentMode = .scaleToFill
scrollEdgeAppearance.backgroundColor = .compound.bgCanvasDefault
controller.navigationItem.scrollEdgeAppearance = scrollEdgeAppearance
}
private func makeBloomImage() -> UIImage? {
if let bloomGradientImage {
return bloomGradientImage
}
let newImage = ImageRenderer(content: bloomGradient).uiImage
Task { bloomGradientImage = newImage }
return newImage
}
private var bloomGradient: some View {
LinearGradient(colors: [.compound._bgOwnPill, .clear], // This isn't the final gradient.
startPoint: .top,
endPoint: .bottom)
.ignoresSafeArea(edges: .all)
.frame(width: 100, height: 100)
}
}
struct BloomModifier: ViewModifier {
@ObservedObject var context: HomeScreenViewModel.Context
let scrollViewAdapter: ScrollViewAdapter
// Bloom components
@State private var bloomView: UIView?
@State private var leftBarButtonView: UIView?
@State private var gradientView: UIView?
@State private var navigationBarContainer: UIView?
@State private var hairlineView: UIView?
func body(content: Content) -> some View {
content
.introspect(.viewController, on: .supportedVersions) { controller in
Task {
if bloomView == nil {
makeBloomView(controller: controller)
}
}
let isTopController = controller.navigationController?.topViewController != controller
let isHidden = isTopController || context.isSearchFieldFocused
if let bloomView {
bloomView.isHidden = isHidden
UIView.transition(with: bloomView, duration: 1.75, options: .curveEaseInOut) {
bloomView.alpha = isTopController ? 0 : 1
}
}
gradientView?.isHidden = isHidden
navigationBarContainer?.clipsToBounds = !isHidden
hairlineView?.isHidden = isHidden || !scrollViewAdapter.isAtTopEdge.value
if !isHidden {
updateBloomCenter()
}
}
.onReceive(scrollViewAdapter.isAtTopEdge.removeDuplicates()) { value in
hairlineView?.isHidden = !value
guard let gradientView else {
return
}
if value {
UIView.transition(with: gradientView, duration: 0.3, options: .curveEaseIn) {
gradientView.alpha = 0
}
} else {
gradientView.alpha = 1
}
}
}
private var bloomGradient: some View {
LinearGradient(colors: [.clear, .compound.bgCanvasDefault], startPoint: .top, endPoint: .bottom)
.mask {
LinearGradient(stops: [.init(color: .white, location: 0.75), .init(color: .clear, location: 1.0)],
startPoint: .leading,
endPoint: .trailing)
}
.ignoresSafeArea(edges: .all)
}
private func makeBloomView(controller: UIViewController) {
guard let navigationBarContainer = controller.navigationController?.navigationBar.subviews.first,
let leftBarButtonView = controller.navigationItem.leadingItemGroups.first?.barButtonItems.first?.customView else {
return
}
let bloomController = UIHostingController(rootView: bloom)
bloomController.view.translatesAutoresizingMaskIntoConstraints = true
bloomController.view.backgroundColor = .clear
navigationBarContainer.insertSubview(bloomController.view, at: 0)
self.leftBarButtonView = leftBarButtonView
bloomView = bloomController.view
self.navigationBarContainer = navigationBarContainer
updateBloomCenter()
let gradientController = UIHostingController(rootView: bloomGradient)
gradientController.view.backgroundColor = .clear
gradientController.view.translatesAutoresizingMaskIntoConstraints = false
navigationBarContainer.insertSubview(gradientController.view, aboveSubview: bloomController.view)
let constraints = [gradientController.view.bottomAnchor.constraint(equalTo: navigationBarContainer.bottomAnchor),
gradientController.view.trailingAnchor.constraint(equalTo: navigationBarContainer.trailingAnchor),
gradientController.view.leadingAnchor.constraint(equalTo: navigationBarContainer.leadingAnchor),
gradientController.view.heightAnchor.constraint(equalToConstant: 40)]
constraints.forEach { $0.isActive = true }
gradientView = gradientController.view
let dividerController = UIHostingController(rootView: Divider().ignoresSafeArea())
dividerController.view.translatesAutoresizingMaskIntoConstraints = false
navigationBarContainer.addSubview(dividerController.view)
let dividerConstraints = [dividerController.view.bottomAnchor.constraint(equalTo: gradientController.view.bottomAnchor),
dividerController.view.widthAnchor.constraint(equalTo: gradientController.view.widthAnchor),
dividerController.view.leadingAnchor.constraint(equalTo: gradientController.view.leadingAnchor)]
dividerConstraints.forEach { $0.isActive = true }
hairlineView = dividerController.view
}
private func updateBloomCenter() {
guard let leftBarButtonView,
let bloomView,
let navigationBarContainer = bloomView.superview else {
return
}
let center = leftBarButtonView.convert(leftBarButtonView.center, to: navigationBarContainer.coordinateSpace)
bloomView.center = center
}
private var bloom: some View {
BloomView(context: context)
}
}
private struct BloomView: View {
@ObservedObject var context: HomeScreenViewModel.Context
@Environment(\.colorScheme) private var colorScheme
var body: some View {
ZStack {
avatar
.blur(radius: 64)
.blendMode(colorScheme == .dark ? .exclusion : .hardLight)
.opacity(colorScheme == .dark ? 0.50 : 0.20)
avatar
.blur(radius: 64)
.blendMode(.color)
.opacity(colorScheme == .dark ? 0.20 : 0.80)
}
}
private var avatar: some View {
LoadableAvatarImage(url: context.viewState.userAvatarURL,
name: context.viewState.userDisplayName,
contentID: context.viewState.userID,
avatarSize: .custom(256),
mediaProvider: context.mediaProvider)
}
}

View File

@@ -1,34 +0,0 @@
//
// Copyright 2023, 2024 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 SwiftUI
struct BloomView: View {
@ObservedObject var context: HomeScreenViewModel.Context
@Environment(\.colorScheme) private var colorScheme
var body: some View {
ZStack {
avatar
.blur(radius: 64)
.blendMode(colorScheme == .dark ? .exclusion : .hardLight)
.opacity(colorScheme == .dark ? 0.50 : 0.20)
avatar
.blur(radius: 64)
.blendMode(.color)
.opacity(colorScheme == .dark ? 0.20 : 0.80)
}
}
private var avatar: some View {
LoadableAvatarImage(url: context.viewState.userAvatarURL,
name: context.viewState.userDisplayName,
contentID: context.viewState.userID,
avatarSize: .custom(256),
mediaProvider: context.mediaProvider)
}
}

View File

@@ -7,21 +7,14 @@
import Combine
import Compound
import SentrySwiftUI
import SwiftUI
import SwiftUIIntrospect
struct HomeScreen: View {
@ObservedObject var context: HomeScreenViewModel.Context
@State private var scrollViewAdapter = ScrollViewAdapter()
// Bloom components
@State private var bloomView: UIView?
@State private var leftBarButtonView: UIView?
@State private var gradientView: UIView?
@State private var navigationBarContainer: UIView?
@State private var hairlineView: UIView?
var body: some View {
HomeScreenContent(context: context, scrollViewAdapter: scrollViewAdapter)
.alert(item: $context.alertInfo)
@@ -32,102 +25,13 @@ struct HomeScreen: View {
.toolbar { toolbar }
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.track(screen: .Home)
.introspect(.viewController, on: .supportedVersions) { controller in
Task {
if bloomView == nil {
makeBloomView(controller: controller)
}
}
let isTopController = controller.navigationController?.topViewController != controller
let isHidden = isTopController || context.isSearchFieldFocused
if let bloomView {
bloomView.isHidden = isHidden
UIView.transition(with: bloomView, duration: 1.75, options: .curveEaseInOut) {
bloomView.alpha = isTopController ? 0 : 1
}
}
gradientView?.isHidden = isHidden
navigationBarContainer?.clipsToBounds = !isHidden
hairlineView?.isHidden = isHidden || !scrollViewAdapter.isAtTopEdge.value
if !isHidden {
updateBloomCenter()
}
}
.onReceive(scrollViewAdapter.isAtTopEdge.removeDuplicates()) { value in
hairlineView?.isHidden = !value
guard let gradientView else {
return
}
if value {
UIView.transition(with: gradientView, duration: 0.3, options: .curveEaseIn) {
gradientView.alpha = 0
}
} else {
gradientView.alpha = 1
}
}
.bloom(context: context,
scrollViewAdapter: scrollViewAdapter,
isNewBloomEnabled: context.viewState.isNewBloomEnabled)
.sentryTrace("\(Self.self)")
}
// MARK: - Private
private var bloomGradient: some View {
LinearGradient(colors: [.clear, .compound.bgCanvasDefault], startPoint: .top, endPoint: .bottom)
.mask {
LinearGradient(stops: [.init(color: .white, location: 0.75), .init(color: .clear, location: 1.0)],
startPoint: .leading,
endPoint: .trailing)
}
.ignoresSafeArea(edges: .all)
}
private func makeBloomView(controller: UIViewController) {
guard let navigationBarContainer = controller.navigationController?.navigationBar.subviews.first,
let leftBarButtonView = controller.navigationItem.leadingItemGroups.first?.barButtonItems.first?.customView else {
return
}
let bloomController = UIHostingController(rootView: bloom)
bloomController.view.translatesAutoresizingMaskIntoConstraints = true
bloomController.view.backgroundColor = .clear
navigationBarContainer.insertSubview(bloomController.view, at: 0)
self.leftBarButtonView = leftBarButtonView
bloomView = bloomController.view
self.navigationBarContainer = navigationBarContainer
updateBloomCenter()
let gradientController = UIHostingController(rootView: bloomGradient)
gradientController.view.backgroundColor = .clear
gradientController.view.translatesAutoresizingMaskIntoConstraints = false
navigationBarContainer.insertSubview(gradientController.view, aboveSubview: bloomController.view)
let constraints = [gradientController.view.bottomAnchor.constraint(equalTo: navigationBarContainer.bottomAnchor),
gradientController.view.trailingAnchor.constraint(equalTo: navigationBarContainer.trailingAnchor),
gradientController.view.leadingAnchor.constraint(equalTo: navigationBarContainer.leadingAnchor),
gradientController.view.heightAnchor.constraint(equalToConstant: 40)]
constraints.forEach { $0.isActive = true }
gradientView = gradientController.view
let dividerController = UIHostingController(rootView: Divider().ignoresSafeArea())
dividerController.view.translatesAutoresizingMaskIntoConstraints = false
navigationBarContainer.addSubview(dividerController.view)
let dividerConstraints = [dividerController.view.bottomAnchor.constraint(equalTo: gradientController.view.bottomAnchor),
dividerController.view.widthAnchor.constraint(equalTo: gradientController.view.widthAnchor),
dividerController.view.leadingAnchor.constraint(equalTo: gradientController.view.leadingAnchor)]
dividerConstraints.forEach { $0.isActive = true }
hairlineView = dividerController.view
}
private func updateBloomCenter() {
guard let leftBarButtonView,
let bloomView,
let navigationBarContainer = bloomView.superview else {
return
}
let center = leftBarButtonView.convert(leftBarButtonView.center, to: navigationBarContainer.coordinateSpace)
bloomView.center = center
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
@@ -152,10 +56,6 @@ struct HomeScreen: View {
}
}
private var bloom: some View {
BloomView(context: context)
}
@ViewBuilder
private var newRoomButton: some View {
switch context.viewState.roomListMode {

View File

@@ -47,6 +47,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
var reportRoomEnabled: Bool { get set }
var reportInviteEnabled: Bool { get set }
var threadsEnabled: Bool { get set }
var isNewBloomEnabled: Bool { get set }
}
extension AppSettings: DeveloperOptionsProtocol { }

View File

@@ -49,6 +49,11 @@ struct DeveloperOptionsScreen: View {
Toggle(isOn: $context.fuzzyRoomListSearchEnabled) {
Text("Fuzzy searching")
}
Toggle(isOn: $context.isNewBloomEnabled) {
Text("New bloom appearance")
Text("Requires app reboot")
}
}
Section("Join rules") {