Фикс: поле ввода следует за клавиатурой без задержки (UIKit-контейнер composer)

This commit is contained in:
2026-03-23 16:23:35 +05:00
parent 9289bb2efd
commit 0b95776968
8 changed files with 497 additions and 203 deletions

View File

@@ -41,78 +41,88 @@ struct AttachmentPanelView: View {
private var hasSelection: Bool { !selectedAssets.isEmpty }
var body: some View {
ZStack(alignment: .bottom) {
// Dark surface background (#1C1C1E NOT pure black, so sheet rounded
// corners are visible against the app's black background behind)
Color(hex: 0x1C1C1E).ignoresSafeArea()
VStack(spacing: 0) {
// Grabber + Toolbar
toolbar
// Content
switch selectedTab {
case .gallery:
PhotoGridView(
selectedAssets: $selectedAssets,
maxSelection: PendingAttachment.maxAttachmentsPerMessage,
onCameraTap: { showCamera = true },
onPhotoPreview: { asset in
previewAsset = IdentifiableAsset(asset: asset)
}
)
case .file:
fileTabContent
case .avatar:
avatarTabContent
panelContent
.sheet(isPresented: $showCamera) {
CameraPickerView { image in
capturedImage = image
handleCapturedImage(image)
}
.ignoresSafeArea()
}
.sheet(isPresented: $showFilePicker) {
DocumentPickerView { urls in
handlePickedFiles(urls)
}
}
.fullScreenCover(item: $previewAsset) { item in
PhotoPreviewView(
asset: item.asset,
isSelected: selectedAssets.contains(where: { $0.localIdentifier == item.id }),
selectionNumber: selectedAssets.firstIndex(where: { $0.localIdentifier == item.id }).map { $0 + 1 },
captionText: $captionText,
onSend: { image in
let caption = captionText
let attachment = PendingAttachment.fromImage(image)
onSend([attachment], caption)
dismiss()
},
onToggleSelect: {
if let idx = selectedAssets.firstIndex(where: { $0.localIdentifier == item.id }) {
selectedAssets.remove(at: idx)
} else if selectedAssets.count < PendingAttachment.maxAttachmentsPerMessage {
selectedAssets.append(item.asset)
}
}
)
.background(TransparentFullScreenBackground())
}
.onPreferenceChange(AttachTabWidthKey.self) { tabWidths.merge($0) { _, new in new } }
.onPreferenceChange(AttachTabOriginKey.self) { tabOrigins.merge($0) { _, new in new } }
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.attachmentCornerRadius(20)
.attachmentSheetBackground()
.preferredColorScheme(.dark)
}
// MARK: - Panel Content
/// Figma: photos extend to bottom, glass tab bar pill floats over them.
/// iOS 26+: no background color default Liquid Glass sheet.
/// iOS < 26: dark background + gradient behind tab bar.
@ViewBuilder
private var panelContent: some View {
ZStack(alignment: .bottom) {
if #unavailable(iOS 26) {
Color(hex: 0x1C1C1E).ignoresSafeArea()
}
VStack(spacing: 0) {
toolbar
tabContent
Spacer(minLength: 0)
}
// Bottom: Tab bar + Send button
bottomBar
}
.sheet(isPresented: $showCamera) {
CameraPickerView { image in
capturedImage = image
handleCapturedImage(image)
}
.ignoresSafeArea()
}
.sheet(isPresented: $showFilePicker) {
DocumentPickerView { urls in
handlePickedFiles(urls)
}
}
.fullScreenCover(item: $previewAsset) { item in
PhotoPreviewView(
asset: item.asset,
isSelected: selectedAssets.contains(where: { $0.localIdentifier == item.id }),
selectionNumber: selectedAssets.firstIndex(where: { $0.localIdentifier == item.id }).map { $0 + 1 },
captionText: $captionText,
onSend: { image in
let caption = captionText
let attachment = PendingAttachment.fromImage(image)
onSend([attachment], caption)
dismiss()
},
onToggleSelect: {
if let idx = selectedAssets.firstIndex(where: { $0.localIdentifier == item.id }) {
selectedAssets.remove(at: idx)
} else if selectedAssets.count < PendingAttachment.maxAttachmentsPerMessage {
selectedAssets.append(item.asset)
}
}
/// Tab content for the selected tab shared between iOS versions.
@ViewBuilder
private var tabContent: some View {
switch selectedTab {
case .gallery:
PhotoGridView(
selectedAssets: $selectedAssets,
maxSelection: PendingAttachment.maxAttachmentsPerMessage,
onCameraTap: { showCamera = true },
onPhotoPreview: { asset in
previewAsset = IdentifiableAsset(asset: asset)
}
)
.background(TransparentFullScreenBackground())
case .file:
fileTabContent
case .avatar:
avatarTabContent
}
.onPreferenceChange(AttachTabWidthKey.self) { tabWidths.merge($0) { _, new in new } }
.onPreferenceChange(AttachTabOriginKey.self) { tabOrigins.merge($0) { _, new in new } }
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.attachmentCornerRadius(20)
.preferredColorScheme(.dark)
}
// MARK: - Toolbar (Telegram-style: dark surface header)
@@ -310,16 +320,16 @@ struct AttachmentPanelView: View {
// MARK: - Bottom Bar
/// Figma: Tab Bar at absolute bottom with padding 25px horizontal, 25px bottom.
/// Glass pill floats over photos no footer, no dark strips.
private var bottomBar: some View {
VStack(spacing: 0) {
if hasSelection {
// Caption input bar (replaces tab bar when photos selected)
captionInputBar
.padding(.horizontal, 16)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .bottom)))
} else {
// Tab bar (Figma: node 4758:50706 glass capsule)
tabBar
.padding(.horizontal, 25)
.padding(.bottom, 12)
@@ -327,18 +337,22 @@ struct AttachmentPanelView: View {
}
}
.animation(.easeInOut(duration: 0.25), value: hasSelection)
.background(
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black.opacity(0.6), location: 0.3),
.init(color: .black, location: 0.8),
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea(edges: .bottom)
)
.background {
// iOS < 26: gradient fade behind tab bar (dark theme).
// iOS 26+: no gradient Liquid Glass pill is self-contained.
if #unavailable(iOS 26) {
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black.opacity(0.6), location: 0.3),
.init(color: .black, location: 0.8),
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea(edges: .bottom)
}
}
}
// MARK: - Caption Input Bar (matches ChatDetail composer style)
@@ -389,33 +403,36 @@ struct AttachmentPanelView: View {
TelegramGlassRoundedRect(cornerRadius: 21)
}
// MARK: - Tab Bar (RosettaTabBar parity: glass capsule + sliding indicator)
// MARK: - Tab Bar
/// Glass capsule tab bar matching RosettaTabBar pattern exactly.
/// Tabs: Gallery | File | Avatar.
/// Selection indicator: sliding glass pill behind selected tab (spring animation).
/// Glass capsule pill floating over photos (Figma: node 6413:9562).
/// Uses TelegramGlassCapsule for all iOS renders UIGlassEffect on iOS 26+.
/// TabView(.tabBarOnly) removed: expands greedily, blocks touches, adds dark strips.
private var tabBar: some View {
legacyTabBar
}
// MARK: - Glass Capsule Tab Bar (all iOS versions)
private var legacyTabBar: some View {
HStack(spacing: 0) {
tabButton(.gallery, icon: "photo.fill", label: "Gallery")
legacyTabButton(.gallery, icon: "photo.fill", unselectedIcon: "photo", label: "Gallery")
.background(tabWidthReader(.gallery))
tabButton(.file, icon: "doc.fill", label: "File")
legacyTabButton(.file, icon: "doc.fill", unselectedIcon: "doc", label: "File")
.background(tabWidthReader(.file))
tabButton(.avatar, icon: "person.crop.circle.fill", label: "Avatar")
legacyTabButton(.avatar, icon: "person.crop.circle.fill", unselectedIcon: "person.crop.circle", label: "Avatar")
.background(tabWidthReader(.avatar))
}
.padding(4)
.coordinateSpace(name: "attachTabBar")
.background(alignment: .leading) {
// Sliding selection indicator (RosettaTabBar parity)
attachmentSelectionIndicator
legacySelectionIndicator
}
.background { TelegramGlassCapsule() }
.clipShape(Capsule())
.contentShape(Capsule())
.tabBarShadow()
}
/// Reads tab width and origin for selection indicator positioning.
private func tabWidthReader(_ tab: AttachmentTab) -> some View {
GeometryReader { geo in
Color.clear.preference(
@@ -429,47 +446,35 @@ struct AttachmentPanelView: View {
}
}
/// Sliding glass pill behind the selected tab (matches RosettaTabBar).
@ViewBuilder
private var attachmentSelectionIndicator: some View {
private var legacySelectionIndicator: some View {
let width = tabWidths[selectedTab] ?? 0
let xOffset = tabOrigins[selectedTab] ?? 0
if #available(iOS 26, *) {
Capsule().fill(.clear)
.glassEffect(.regular, in: .capsule)
.allowsHitTesting(false)
.frame(width: width)
.offset(x: xOffset)
.animation(.spring(response: 0.34, dampingFraction: 0.82), value: selectedTab)
} else {
Capsule().fill(.thinMaterial)
.frame(width: max(0, width - 4))
.padding(.vertical, 4)
.offset(x: xOffset + 2)
.animation(.spring(response: 0.34, dampingFraction: 0.82), value: selectedTab)
}
return Capsule().fill(.thinMaterial)
.frame(width: max(0, width - 4))
.padding(.vertical, 4)
.offset(x: xOffset + 2)
.animation(.spring(response: 0.34, dampingFraction: 0.82), value: selectedTab)
}
/// Individual tab button matching RosettaTabBar dimensions exactly.
/// Icon: 22pt regular (frame height 28), Label: 10pt, VStack spacing: 2, padding: 6pt.
private func tabButton(_ tab: AttachmentTab, icon: String, label: String) -> some View {
private func legacyTabButton(_ tab: AttachmentTab, icon: String, unselectedIcon: String, label: String) -> some View {
let isSelected = selectedTab == tab
let tint = isSelected ? Color(hex: 0x008BFF) : .white
return Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
selectedTab = tab
}
} label: {
VStack(spacing: 2) {
Image(systemName: icon)
Image(systemName: isSelected ? icon : unselectedIcon)
.font(.system(size: 22, weight: .regular))
.foregroundStyle(tint)
.frame(height: 28)
Text(label)
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
.foregroundStyle(tint)
}
// RosettaTabBar colors: selected=#008BFF, unselected=white
.foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white)
.frame(minWidth: 66, maxWidth: .infinity)
.padding(.vertical, 6)
}
@@ -704,6 +709,28 @@ private extension View {
}
}
// MARK: - Sheet Background (iOS < 26 only; iOS 26+ uses default Liquid Glass)
private struct AttachmentSheetBackgroundModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
// iOS 26+: use default Liquid Glass sheet background.
content
} else if #available(iOS 16.4, *) {
// iOS < 26: opaque dark background (no glass on older iOS sheets).
content.presentationBackground(Color(hex: 0x1C1C1E))
} else {
content
}
}
}
private extension View {
func attachmentSheetBackground() -> some View {
modifier(AttachmentSheetBackgroundModifier())
}
}
// MARK: - CameraPickerView
/// UIKit camera wrapper for taking photos.

View File

@@ -2,7 +2,7 @@ import SwiftUI
import UIKit
import UserNotifications
/// Measures the composer height so the inverted scroll can reserve bottom space.
/// Measures the composer height so the scroll can reserve bottom space.
private struct ComposerHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
@@ -10,15 +10,30 @@ private struct ComposerHeightKey: PreferenceKey {
}
}
/// Reads keyboardPadding in its own observation scope
/// parent body is NOT re-evaluated on padding changes.
/// Reserves space at the bottom of the scroll content for the composer + keyboard.
/// iOS < 26: inputAccessoryView handles composer, spacer includes keyboard height
/// so messages stay above the keyboard. keyboardPadding from notification (instant).
/// iOS 26+: SwiftUI handles keyboard natively, spacer only for composer overlay.
private struct KeyboardSpacer: View {
@ObservedObject private var keyboard = KeyboardTracker.shared
let composerHeight: CGFloat
var body: some View {
let _ = PerformanceLogger.shared.track("keyboardSpacer.bodyEval")
Color.clear.frame(height: composerHeight + keyboard.keyboardPadding + 4)
let height: CGFloat = {
if #available(iOS 26, *) {
return composerHeight
} else {
// Inverted scroll: spacer at VStack START. Growing it pushes
// messages away from offset=0 visually UP. CADisplayLink
// animates keyboardPadding in sync with keyboard curve.
return composerHeight + keyboard.keyboardPadding + 4
}
}()
#if DEBUG
let _ = { print("📏 Spacer | h=\(Int(height)) kbPad=\(Int(keyboard.keyboardPadding)) compH=\(Int(composerHeight))") }()
#endif
Color.clear.frame(height: max(height, 0))
}
}
@@ -53,7 +68,9 @@ private struct EmptyStateKeyboardOffset<Content: View>: View {
}
var body: some View {
content.offset(y: -keyboard.keyboardPadding / 2)
// keyboardPadding is 0 (KeyboardTracker is inert for both iOS versions).
// Empty state doesn't need keyboard offset composer handles positioning.
content
}
}
@@ -181,16 +198,19 @@ struct ChatDetailView: View {
}
.overlay { chatEdgeGradients }
// FPS overlay uncomment for performance testing:
.overlay { FPSOverlayView() }
.overlay(alignment: .bottom) {
// .overlay { FPSOverlayView() }
// Composer overlay always visible, no becomeFirstResponder delay.
// iOS < 26: offset by keyboardPadding (from notification + withAnimation).
// iOS 26+: SwiftUI handles keyboard natively (keyboardPadding = 0).
.overlay {
if !route.isSystemAccount {
KeyboardPaddedView {
composer
.background(
GeometryReader { geo in
Color.clear.preference(key: ComposerHeightKey.self, value: geo.size.height)
}
)
if #available(iOS 26, *) {
ComposerOverlay(composer: composer, composerHeight: $composerHeight)
} else {
ComposerUIKitContainer(content: composer) { height in
if abs(height - composerHeight) > 1 { composerHeight = height }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
@@ -693,14 +713,16 @@ private extension ChatDetailView {
ScrollViewReader { proxy in
let scroll = ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
// Anchor at VStack start after flip = visual BOTTOM (newest edge).
// Anchor at VStack START after flip = visual BOTTOM (newest edge).
// scrollTo(anchor, .top) places this at viewport top = visual bottom.
Color.clear
.frame(height: 4)
.id(Self.scrollBottomAnchorId)
// Spacer for composer + keyboard OUTSIDE LazyVStack.
// Isolated in KeyboardSpacer to avoid marking parent dirty.
// In inverted scroll, spacer at START pushes messages away from
// offset=0. When spacer grows (keyboard opens), messages move up
// visually no scrollTo needed, no defaultScrollAnchor needed.
KeyboardSpacer(composerHeight: composerHeight)
// LazyVStack: only visible cells are loaded.
@@ -713,8 +735,6 @@ private extension ChatDetailView {
.onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } }
.onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } }
// PERF: iterate reversed messages directly, avoid Array(enumerated()) allocation.
// Use message.id identity (stable) integer indices shift on insert.
// PERF: VStack wrapper ensures each ForEach element produces
// exactly 1 view SwiftUI uses FAST PATH (O(1) diffing).
// Without it: conditional unreadSeparator makes element count
@@ -746,8 +766,6 @@ private extension ChatDetailView {
// effects overlap and blur the entire screen.
.modifier(DisableScrollEdgeEffectModifier())
.scaleEffect(x: 1, y: -1) // INVERTED SCROLL bottom-anchored by nature
// Parent .ignoresSafeArea(.keyboard) handles keyboard no scroll-level ignore needed.
// Composer is overlay (not safeAreaInset), so no .container ignore needed either.
.scrollDismissesKeyboard(.interactively)
.onTapGesture { isInputFocused = false }
.onAppear {
@@ -763,10 +781,6 @@ private extension ChatDetailView {
}
shouldScrollOnNextMessage = false
}
// Android parity: markVisibleMessagesAsRead when new incoming
// messages appear while chat is open, mark as read and send receipt.
// Safe to call repeatedly: markAsRead guards unreadCount > 0,
// sendReadReceipt deduplicates by timestamp.
if isViewActive && !lastIsOutgoing
&& !route.isSavedMessages && !route.isSystemAccount {
markDialogAsRead()
@@ -779,7 +793,6 @@ private extension ChatDetailView {
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(targetId, anchor: .center)
}
// Brief highlight glow after scroll completes.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
withAnimation(.easeIn(duration: 0.2)) {
highlightedMessageId = targetId
@@ -2764,9 +2777,24 @@ enum TelegramIconPath {
static let microphone = #"M3.69141 5.09766C3.69141 4.16016 3.91602 3.30078 4.36523 2.51953C4.79492 1.75781 5.38086 1.14258 6.12305 0.673828C6.88477 0.224609 7.70508 0 8.58398 0C9.44336 0 10.2441 0.214844 10.9863 0.644531C11.7285 1.07422 12.3145 1.66016 12.7441 2.40234C13.1934 3.16406 13.4375 3.98438 13.4766 4.86328V5.09766V10.8105C13.4766 11.748 13.252 12.6074 12.8027 13.3887C12.373 14.1504 11.7871 14.7559 11.0449 15.2051C10.2832 15.6738 9.46289 15.9082 8.58398 15.9082C7.72461 15.9082 6.92383 15.6934 6.18164 15.2637C5.43945 14.834 4.85352 14.248 4.42383 13.5059C3.97461 12.7441 3.73047 11.9238 3.69141 11.0449V10.8105V5.09766ZM8.58398 1.58203C7.99805 1.58203 7.45117 1.72852 6.94336 2.02148C6.43555 2.31445 6.03516 2.71484 5.74219 3.22266C5.42969 3.73047 5.25391 4.28711 5.21484 4.89258V5.09766V10.8105C5.21484 11.4551 5.37109 12.0508 5.68359 12.5977C5.97656 13.125 6.37695 13.5449 6.88477 13.8574C7.41211 14.1699 7.97852 14.3262 8.58398 14.3262C9.16992 14.3262 9.7168 14.1797 10.2246 13.8867C10.7324 13.5938 11.1328 13.1934 11.4258 12.6855C11.7383 12.1777 11.9141 11.6211 11.9531 11.0156V10.8105V5.09766C11.9531 4.45312 11.7969 3.85742 11.4844 3.31055C11.1914 2.7832 10.791 2.36328 10.2832 2.05078C9.75586 1.73828 9.18945 1.58203 8.58398 1.58203ZM9.3457 19.7168V22.7637C9.3457 22.9785 9.26758 23.1641 9.11133 23.3203C8.97461 23.4766 8.79883 23.5547 8.58398 23.5547C8.38867 23.5547 8.22266 23.4863 8.08594 23.3496C7.92969 23.2324 7.8418 23.0762 7.82227 22.8809V22.7637V19.7168C6.74805 19.5996 5.72266 19.2969 4.74609 18.8086C3.80859 18.3203 2.98828 17.666 2.28516 16.8457C1.5625 16.0449 1.00586 15.1367 0.615234 14.1211C0.205078 13.0664 0 11.9629 0 10.8105C0 10.5957 0.078125 10.4102 0.234375 10.2539C0.390625 10.0977 0.566406 10.0195 0.761719 10.0195C0.976562 10.0195 1.16211 10.0977 1.31836 10.2539C1.45508 10.4102 1.52344 10.5957 1.52344 10.8105C1.52344 11.8066 1.70898 12.7637 2.08008 13.6816C2.45117 14.5605 2.95898 15.332 3.60352 15.9961C4.24805 16.6797 4.99023 17.207 5.83008 17.5781C6.70898 17.9688 7.62695 18.1641 8.58398 18.1641C9.54102 18.1641 10.459 17.9688 11.3379 17.5781C12.1777 17.207 12.9199 16.6797 13.5645 15.9961C14.209 15.332 14.7168 14.5605 15.0879 13.6816C15.459 12.7637 15.6445 11.8066 15.6445 10.8105C15.6445 10.5957 15.7129 10.4102 15.8496 10.2539C16.0059 10.0977 16.1914 10.0195 16.4062 10.0195C16.6016 10.0195 16.7773 10.0977 16.9336 10.2539C17.0898 10.4102 17.168 10.5957 17.168 10.8105C17.168 11.9629 16.9629 13.0664 16.5527 14.1211C16.1621 15.1367 15.6055 16.0449 14.8828 16.8457C14.1797 17.666 13.3594 18.3203 12.4219 18.8086C11.4453 19.2969 10.4199 19.5996 9.3457 19.7168Z"#
}
/// iOS < 26: ignore keyboard safe area (manual KeyboardTracker handles offset).
/// iOS 26+: let SwiftUI handle keyboard natively no manual tracking.
/// iOS < 26: ignore keyboard safe area keyboardPadding (sync view + CADisplayLink)
/// drives both composer offset and spacer height. One source of truth = perfect sync.
/// iOS 26+: SwiftUI handles keyboard natively.
private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier {
func body(content: Content) -> some View {
// Both iOS versions: disable SwiftUI's native keyboard avoidance.
// Inverted scroll (scaleEffect y: -1) breaks native avoidance it pushes
// content in the wrong direction. KeyboardSpacer + ComposerOverlay handle
// keyboard offset manually via KeyboardTracker.
content.ignoresSafeArea(.keyboard)
}
}
/// iOS < 26: prevent ScrollView from adjusting for keyboard
/// parent .safeAreaInset already handles it. Without this,
/// both parent AND ScrollView adjust double-counting jerky animation.
/// iOS 26 handles this internally.
private struct ScrollIgnoreKeyboardLegacy: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
@@ -2776,6 +2804,45 @@ private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier {
}
}
/// Sets initial scroll position to bottom.
/// iOS 18+: `.initialOffset` only don't re-anchor on container size changes
/// (keyboard open/close causes jumps when re-anchoring).
/// iOS 17: `.defaultScrollAnchor(.bottom)` (no role API available).
private struct DefaultScrollAnchorModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 18, *) {
content.defaultScrollAnchor(.bottom, for: .initialOffset)
} else {
content.defaultScrollAnchor(.bottom)
}
}
}
/// Composer overlay with keyboard offset observation isolated.
/// Composer offset by keyboardPadding driven by sync view + CADisplayLink,
/// reading the keyboard's REAL position each frame. Pixel-perfect sync.
private struct ComposerOverlay<C: View>: View {
let composer: C
@Binding var composerHeight: CGFloat
@ObservedObject private var keyboard = KeyboardTracker.shared
var body: some View {
let pad = keyboard.keyboardPadding
#if DEBUG
let _ = {
print("🎹 Composer | pad=\(Int(pad)) composerH=\(Int(composerHeight))")
}()
#endif
composer
.background(
GeometryReader { geo in
Color.clear.preference(key: ComposerHeightKey.self, value: geo.size.height)
}
)
.padding(.bottom, pad)
}
}
/// iOS 26: scroll edge blur is on by default in inverted scroll (scaleEffect y: -1)
/// both top+bottom edge effects overlap and blur the entire screen.
/// Hide only the ScrollView's top edge (= visual bottom after inversion, near composer).