From defd49ac03c7c55367c118fdf66b0a9041d600f0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 19:09:58 -0400 Subject: [PATCH] feat(profile): fade a barless profile's bottom the way the chat does The barless profile used the pinned bar's fade: 56pt that turns mostly opaque early, over a solid block behind the home indicator. It now uses the chat's dissolve, which stays clear most of the way and reaches 90% only at the screen's bottom edge, so content under the home indicator still shows faintly. ChatFade moves the curve out of ComposerFadeView so both draw the same ramp. Profiles with a pinned button keep their own fade, which the button's text needs to stay readable. --- .../Screens/Profile/ChatProfileScreen.swift | 2 +- .../Profile/ProfilePinnedBackdrop.swift | 18 +++++++++ .../Screens/Profile/UserProfileScreen.swift | 3 +- .../FlipcashUI/Chat/ComposerFade.swift | 37 +++++++++++++------ 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 50d1e9c4a..b419e847b 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -342,7 +342,7 @@ struct ChatProfileScreen: View { if origin == .chat { Color.clear .frame(height: 0) - .profilePinnedBackdrop(isActive: scrollFit.overflows) + .profileBarlessFade(isActive: scrollFit.overflows) } else if conversation != nil { VStack(spacing: 8) { openChatButton diff --git a/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift b/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift index d6a6b9c94..4f2531061 100644 --- a/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift +++ b/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift @@ -39,6 +39,24 @@ extension View { } } + /// Fades content into the bottom of a profile that pins no bar, the way the chat's transcript + /// dissolves under its composer: a ramp from the clearance's top edge to the screen's bottom edge. + /// `isActive` false draws nothing, for a screen whose content doesn't scroll. + func profileBarlessFade(isActive: Bool) -> some View { + background { + if isActive { + LinearGradient( + gradient: ChatFade.gradient(Color.backgroundMain), + startPoint: .top, + endPoint: .bottom + ) + .padding(.top, -ProfilePinnedBackdrop.fadeHeight) + .ignoresSafeArea(edges: .bottom) + .allowsHitTesting(false) + } + } + } + /// Ends a profile's scroll content above the fade ``profilePinnedBackdrop()`` draws over the /// pinned bar, so the last row is readable when scrolled to the bottom. Apply to the scroll view; /// `isActive` false leaves the margin off a screen that draws no backdrop. diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 625528d02..2f9e16809 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -188,7 +188,8 @@ private struct UserProfileContent: View { // On iOS 26 the pinned button joins the bottom scroll edge effect, so content fades under it. .scrollEdgeBar(.bottom) { pinnedButton - .profilePinnedBackdrop(isActive: !hidesPinnedBar || scrollFit.overflows) + .profilePinnedBackdrop(isActive: !hidesPinnedBar) + .profileBarlessFade(isActive: hidesPinnedBar && scrollFit.overflows) // Toasts rise above the button rather than covering it. .toastClearance(toasts) } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift index a79df81c4..22dc37a72 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift @@ -8,6 +8,29 @@ import SwiftUI import UIKit +/// The chat's bottom dissolve as a ramp of background opacities: clear for most of its height, then +/// a late rise that stops short of opaque, so content behind the bottom edge still shows faintly. +public enum ChatFade { + + private static let curve = UnitCurve.bezier( + startControlPoint: UnitPoint(x: 0.42, y: 0.18), + endControlPoint: UnitPoint(x: 0.17, y: 1.04) + ) + private static let endOpacity = 0.9 + private static let samples = 32 + + /// The ramp sampled top to bottom, as a location and the background's opacity there. + static let stops: [(location: Double, opacity: Double)] = (0...samples).map { + let location = Double($0) / Double(samples) + return (location, endOpacity * curve.value(at: location)) + } + + /// The ramp in `color`, for a top-to-bottom gradient. + public static func gradient(_ color: Color) -> Gradient { + Gradient(stops: stops.map { .init(color: color.opacity($0.opacity), location: $0.location) }) + } +} + /// The dissolve from the transcript into the bottom of the screen: a ramp from nothing to the chat /// background, starting at the bar's top edge and reaching full opacity only at the screen's bottom /// edge, or the keyboard's top edge while one is up. The bar floats over it with no surface of its @@ -19,15 +42,6 @@ import UIKit @MainActor final class ComposerFadeView: UIView { - /// The ramp's shape: clear for most of its height, then a late rise into the background. - private static let curve = UnitCurve.bezier( - startControlPoint: UnitPoint(x: 0.42, y: 0.18), - endControlPoint: UnitPoint(x: 0.17, y: 1.04) - ) - /// Short of opaque, so a bubble behind the bar's bottom edge still shows faintly. - private static let endOpacity = 0.9 - private static let samples = 32 - private let colorRamp = GradientView() init() { @@ -35,10 +49,9 @@ final class ComposerFadeView: UIView { isUserInteractionEnabled = false addSubview(colorRamp) let base = UIColor(Color.backgroundMain) - let stops = (0...Self.samples).map { Double($0) / Double(Self.samples) } colorRamp.set( - colors: stops.map { base.withAlphaComponent(Self.endOpacity * Self.curve.value(at: $0)) }, - locations: stops.map { NSNumber(value: $0) } + colors: ChatFade.stops.map { base.withAlphaComponent($0.opacity) }, + locations: ChatFade.stops.map { NSNumber(value: $0.location) } ) }