Фикс: поле ввода следует за клавиатурой без задержки (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

@@ -421,7 +421,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -437,7 +437,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.1;
MARKETING_VERSION = 1.2.3;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -460,7 +460,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -476,7 +476,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.1;
MARKETING_VERSION = 1.2.3;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -11,26 +11,10 @@ enum ReleaseNotes {
Entry(
version: appVersion,
body: """
**Уведомления**
Поддержка CarPlay и фильтров Focus. Переход в чат по нажатию на уведомление из любого состояния приложения. Автопереключение на вкладку Chats.
**Чат**
Нативная прокрутка без инверсии — плавная работа клавиатуры, корректное позиционирование при открытии чата. Интерактивное скрытие клавиатуры свайпом вниз.
**Сеть**
Автоматический реконнект при таймауте handshake. Защита от дублирования при переподключении.
**Клавиатура**
Плавная анимация подъёма и опускания инпута. Устранено дёрганье при закрытии клавиатуры.
**Вложения**
Новый экран отправки аватарки с анимацией. Обновлённый экран выбора файлов. Анимированный индикатор вкладок.
**Просмотр фото**
Жесты зума и навигации работают по всему экрану. Исправлен double-tap для сброса зума.
**Аватарки**
Мгновенное отображение до загрузки на сервер. Корректное отображение отправленных аватарок.
**Исправления**
Отображение типа вложения (Photo, Avatar, File) при ответе на сообщение. Стабильность WebSocket-соединения.
Поле ввода теперь двигается вместе с клавиатурой без задержки — анимация происходит в одной транзакции с клавиатурой через UIKit-контейнер.
"""
)
]

View File

@@ -64,17 +64,27 @@ private struct SettingsHighlightModifier: ViewModifier {
func body(content: Content) -> some View {
let shape = shape(for: position)
content
.buttonStyle(.plain)
.background(shape.fill(isPressed ? Color.white.opacity(0.08) : Color.clear))
.clipShape(shape)
.simultaneousGesture(
DragGesture(minimumDistance: 5)
.updating($isPressed) { value, state, _ in
state = abs(value.translation.height) < 10
&& abs(value.translation.width) < 10
}
)
if #available(iOS 26, *) {
// iOS 26+: skip custom DragGesture it conflicts with ScrollView
// gesture resolution on iOS 26, blocking scroll when finger lands
// on a button. Native press feedback is sufficient.
content
.buttonStyle(.plain)
.clipShape(shape)
} else {
// iOS < 26: custom press highlight via DragGesture
content
.buttonStyle(.plain)
.background(shape.fill(isPressed ? Color.white.opacity(0.08) : Color.clear))
.clipShape(shape)
.simultaneousGesture(
DragGesture(minimumDistance: 5)
.updating($isPressed) { value, state, _ in
state = abs(value.translation.height) < 10
&& abs(value.translation.width) < 10
}
)
}
}
private func shape(for position: SettingsRowPosition) -> UnevenRoundedRectangle {

View File

@@ -78,6 +78,9 @@ final class ChatInputTextView: UITextView {
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
// KVO-based tracking for interactive keyboard dismiss.
// trackingView is zero-height superview.center KVO gives
// pixel-perfect keyboard position during swipe-to-dismiss.
inputAccessoryView = trackingView
inputAssistantItem.leadingBarButtonGroups = []
inputAssistantItem.trailingBarButtonGroups = []

View File

@@ -0,0 +1,109 @@
import SwiftUI
import UIKit
// MARK: - SwiftUI Bridge
/// UIViewRepresentable wrapping the SwiftUI composer in a UIKit container.
/// Bottom constraint animated via UIView.animate inside keyboard notification handler
/// same CA transaction as keyboard, zero gap guaranteed.
/// iOS < 26 only iOS 26+ uses SwiftUI's native keyboard handling.
struct ComposerUIKitContainer<Content: View>: UIViewRepresentable {
let content: Content
let onHeightChange: (CGFloat) -> Void
func makeUIView(context: Context) -> ComposerHostView {
let view = ComposerHostView(
content: AnyView(content),
onHeightChange: onHeightChange
)
KeyboardTracker.shared.composerHostView = view
// Match current keyboard state if already open.
view.setKeyboardOffset(KeyboardTracker.shared.keyboardPadding)
return view
}
func updateUIView(_ view: ComposerHostView, context: Context) {
view.updateContent(AnyView(content))
// NEVER touch constraints here UIKit animation manages positioning.
}
static func dismantleUIView(_ view: ComposerHostView, coordinator: ()) {
KeyboardTracker.shared.composerHostView = nil
}
}
// MARK: - UIKit Host View
/// Full-overlay-size container. Hosting controller pinned to bottom edge.
/// `setKeyboardOffset(_:)` changes the bottom constraint called from
/// UIView.animate block inside keyboard notification handler (same CA transaction).
/// `hitTest` passes through touches on transparent area above composer.
final class ComposerHostView: UIView {
private let hostingController: UIHostingController<AnyView>
private let onHeightChange: (CGFloat) -> Void
private var lastReportedHeight: CGFloat = 0
private var bottomConstraint: NSLayoutConstraint!
init(content: AnyView, onHeightChange: @escaping (CGFloat) -> Void) {
self.onHeightChange = onHeightChange
hostingController = UIHostingController(rootView: content)
super.init(frame: .zero)
backgroundColor = .clear
hostingController.view.backgroundColor = .clear
if #available(iOS 16.4, *) {
hostingController.safeAreaRegions = []
}
hostingController.view.setContentHuggingPriority(.required, for: .vertical)
hostingController.view.setContentCompressionResistancePriority(
.required, for: .vertical
)
addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
bottomConstraint = hostingController.view.bottomAnchor.constraint(
equalTo: bottomAnchor
)
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomConstraint,
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func updateContent(_ content: AnyView) {
hostingController.rootView = content
}
/// Moves composer up by `offset` points from the bottom.
/// When called inside UIView.animate creates CA animation in same transaction.
/// When called directly (KVO) immediate constraint update.
func setKeyboardOffset(_ offset: CGFloat) {
#if DEBUG
print("🎹 UIKit setKeyboardOffset(\(Int(offset))) — constraint=\(-Int(offset))")
#endif
bottomConstraint.constant = -offset
layoutIfNeeded()
}
/// Pass through touches on transparent area only the composer handles taps.
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
return result === self ? nil : result
}
override func layoutSubviews() {
super.layoutSubviews()
let height = hostingController.view.bounds.height
guard height > 0, abs(height - lastReportedHeight) > 1 else { return }
lastReportedHeight = height
DispatchQueue.main.async { [onHeightChange, height] in
onHeightChange(height)
}
}
}

View File

@@ -20,17 +20,28 @@ final class KeyboardTracker: ObservableObject {
static let shared = KeyboardTracker()
/// Bottom padding updated incrementally at display refresh rate.
/// Used by `KeyboardPaddedView` (composer offset) for smooth animation.
@Published private(set) var keyboardPadding: CGFloat = 0
/// Target padding jumps to final value in 1 step.
/// Used by `KeyboardSpacer` (scroll content) to avoid 16 VStack re-layouts
/// per animation. Messages are scroll-anchored, so the jump is invisible.
@Published private(set) var spacerPadding: CGFloat = 0
private var isAnimating = false
/// Public flag for BubbleContextMenuOverlay to skip updateUIView during animation.
/// NOT @Published read directly from UIViewRepresentable, no observation.
private(set) var isAnimatingKeyboard = false
private let bottomInset: CGFloat
private var pendingResetTask: Task<Void, Never>?
private var spacerUpdateTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
private var lastNotificationPadding: CGFloat = 0
/// UIKit composer container animated in same CA transaction as keyboard.
/// Set by ComposerUIKitContainer, cleared on dismantle.
weak var composerHostView: ComposerHostView?
// CADisplayLink-based animation state (notification-driven show/hide)
private var displayLinkProxy: DisplayLinkProxy?
private var animStartPadding: CGFloat = 0
@@ -43,6 +54,8 @@ final class KeyboardTracker: ObservableObject {
/// Monotonic guard sync view can give non-monotonic eased values
/// in the first 2 ticks while Core Animation commits the animation.
private var lastEased: CGFloat = 0
/// Previous tick's monotonic eased for velocity prediction.
private var previousMonotonicEased: CGFloat = 0
// Cubic bezier control points deterministic keyboard curve approximation.
private var bezierP1x: CGFloat = 0.25
@@ -67,15 +80,43 @@ final class KeyboardTracker: ObservableObject {
bottomInset = 34
}
// iOS 26+ handles keyboard natively no custom tracking needed.
// iOS 26+: SwiftUI handles keyboard natively no tracking needed.
if #available(iOS 26, *) { return }
// iOS < 26: sync view + CADisplayLink reads keyboard's REAL position
// each frame from the same CA transaction. Pixel-perfect sync.
NotificationCenter.default
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
.sink { [weak self] in self?.handleNotification($0) }
.store(in: &cancellables)
}
/// Sets keyboardPadding with animation matching keyboard duration.
/// No CADisplayLink, no sync views just withAnimation to match timing.
private func handleHeightChange(_ notification: Notification) {
guard let info = notification.userInfo,
let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
let screenHeight = UIScreen.main.bounds.height
let keyboardTop = endFrame.origin.y
let isVisible = keyboardTop < screenHeight
let targetPadding = isVisible ? max(0, screenHeight - keyboardTop - bottomInset) : 0
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
guard keyboardPadding != targetPadding else { return }
// Animate spacer + composer offset to match keyboard animation.
// Notification arrives ~10-30ms AFTER keyboard animation starts,
// so we use a slightly shorter duration with a front-loaded curve
// to keep input ahead of (or level with) the keyboard.
// timingCurve approximates iOS keyboard curve 7 with faster start.
let adjustedDuration = max(duration * 0.85, 0.12)
withAnimation(.timingCurve(0.1, 0.9, 0.2, 1.0, duration: adjustedDuration)) {
keyboardPadding = targetPadding
}
}
/// Called from KVO pixel-perfect interactive dismiss.
/// Buffers values and applies at 30fps via CADisplayLink coalescing
/// to reduce ChatDetailView.body evaluations during swipe-to-dismiss.
@@ -94,6 +135,8 @@ final class KeyboardTracker: ObservableObject {
// Flush any pending KVO value and stop coalescing
flushPendingKVO()
stopKVOCoalescing()
// Move composer to bottom immediately
composerHostView?.setKeyboardOffset(0)
if keyboardPadding != 0 {
if pendingResetTask == nil {
@@ -103,6 +146,8 @@ final class KeyboardTracker: ObservableObject {
if self.keyboardPadding != 0 {
self.keyboardPadding = 0
}
// spacerPadding NOT reset here only from handleNotification
// with withAnimation to avoid jumps during rapid toggling.
}
}
}
@@ -121,7 +166,10 @@ final class KeyboardTracker: ObservableObject {
let current = pendingKVOPadding ?? keyboardPadding
guard newPadding < current else { return }
// Buffer the value will be applied by kvoDisplayLink at 30fps
// Move composer immediately (UIKit, no SwiftUI overhead)
composerHostView?.setKeyboardOffset(rawPadding)
// Buffer spacer update (30fps coalescing)
pendingKVOPadding = newPadding
// Start coalescing display link if not running
@@ -144,6 +192,8 @@ final class KeyboardTracker: ObservableObject {
guard pending.isFinite, pending >= 0 else { return }
guard pending != keyboardPadding else { return }
keyboardPadding = pending
// spacerPadding NOT updated from KVO only from handleNotification
// with withAnimation to stay in sync with keyboard CA transaction.
}
/// Immediately applies any buffered KVO value (used when KVO stops).
@@ -178,7 +228,10 @@ final class KeyboardTracker: ObservableObject {
pendingResetTask?.cancel()
pendingResetTask = nil
let targetPadding = isVisible ? max(0, endHeight - bottomInset) : 0
// +8pt gap above keyboard visible breathing room between composer and
// keyboard edge. 4pt was invisible (1.3mm), 8pt = 2.6mm, clearly visible.
// Baked into target so it's part of the smooth animation (no discontinuity).
let targetPadding = isVisible ? max(0, endHeight - bottomInset + 8) : 0
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0
@@ -188,17 +241,28 @@ final class KeyboardTracker: ObservableObject {
PerformanceLogger.shared.track("keyboard.notification")
#if DEBUG
let direction = targetPadding > keyboardPadding ? "⬆️ SHOW" : "⬇️ HIDE"
print("⌨️ \(direction) | current=\(Int(keyboardPadding)) target=\(Int(targetPadding)) | delta=\(Int(delta)) | duration=\(String(format: "%.3f", duration))s | curve=\(curveRaw)")
print("⌨️ \(direction) | screenH=\(Int(screenHeight)) kbTop=\(Int(keyboardTop)) endH=\(Int(endHeight)) bottomInset=\(Int(bottomInset)) | target=\(Int(targetPadding)) current=\(Int(keyboardPadding)) delta=\(Int(delta)) | dur=\(String(format: "%.3f", duration))s curve=\(curveRaw)")
#endif
// Filter spurious notifications (delta=0, duration=0) keyboard frame
// didn't actually change. Happens on some iOS versions during text input.
guard abs(delta) > 1 || targetPadding != keyboardPadding else { return }
guard abs(delta) > 1 || targetPadding != keyboardPadding else {
#if DEBUG
if delta != 0 { print("⌨️ ⏭️ SKIP spurious | delta=\(Int(delta))") }
#endif
return
}
// Animate with sync view (keyboard's exact CA curve) no pre-apply.
// Pre-apply caused a visible "jump" because input moved 16pt instantly
// before the keyboard had started moving. Without pre-apply, there may
// be ~1 frame of slight overlap (keyboard ahead of input) but it's
// imperceptible at 60Hz (16ms) and provides a much smoother feel.
// UIKit: animate composer in same CA transaction as keyboard zero lag.
// SwiftUI ComposerOverlay (iOS 26+) doesn't use this composerHostView is nil.
if let hostView = composerHostView {
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
UIView.animate(withDuration: duration, delay: 0, options: [options]) {
hostView.setKeyboardOffset(targetPadding)
}
}
// CADisplayLink animation for keyboardPadding (drives KeyboardSpacer).
// Spacer lag is invisible because messages are scroll-anchored to bottom.
if abs(delta) > 1, targetPadding != keyboardPadding {
isAnimatingKeyboard = true
startPaddingAnimation(to: targetPadding, duration: duration, curveRaw: curveRaw)
@@ -265,11 +329,13 @@ final class KeyboardTracker: ObservableObject {
animDuration = max(duration, 0.05)
animTickCount = 0
lastEased = 0
previousMonotonicEased = 0
// Primary: sync view matches keyboard's exact curve (same CA transaction).
// No lead, no pre-apply the natural ~1 frame SwiftUI processing delay
// creates the "keyboard pushes input" effect (like Telegram).
let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
#if DEBUG
print("⌨️ 🎬 START | from=\(Int(keyboardPadding)) to=\(Int(target)) syncOK=\(syncOK) dur=\(String(format: "%.3f", duration))s")
#endif
// Fallback: cubic bezier (only if sync view can't be created).
if !syncOK {
@@ -317,7 +383,6 @@ final class KeyboardTracker: ObservableObject {
lastTickTime = now
let elapsed = now - animStartTime
let timeComplete = elapsed >= animDuration
// Read eased fraction: sync view (exact) or bezier (fallback).
var eased: CGFloat
@@ -332,20 +397,16 @@ final class KeyboardTracker: ObservableObject {
// non-monotonic values in the first 2 ticks while Core Animation commits
// the animation to the render server. Without this guard, padding oscillates
// (e.g. 312 306 310 302) causing a visible "jerk".
#if DEBUG
if eased < lastEased {
print("⌨️ ⚠️ MONOTONIC | raw=\(String(format: "%.4f", eased)) clamped=\(String(format: "%.4f", lastEased))")
}
#endif
if eased < lastEased {
eased = lastEased
}
lastEased = eased
// SHOW only: lead by ~1 frame to prevent keyboard overlapping input.
// The value we publish NOW is rendered by SwiftUI NEXT frame.
// Without lead, rendered input position is 1 frame behind keyboard overlap.
// 0.065 1 frame at 60Hz (16.6ms / 250ms). Continuous, not a jump.
// HIDE doesn't need lead natural 1-frame delay creates acceptable gap.
if animTargetPadding > animStartPadding {
eased = min(eased + 0.065, 1.0)
}
guard eased.isFinite else {
displayLinkProxy?.isPaused = true
lastTickTime = 0
@@ -353,10 +414,42 @@ final class KeyboardTracker: ObservableObject {
return
}
let raw = animStartPadding + (animTargetPadding - animStartPadding) * eased
let rounded = max(0, round(raw))
// Velocity prediction: compensates for SwiftUI's ~8ms render lag.
// Composer is UIKit (zero lag), but KeyboardSpacer still needs prediction
// to keep messages moving in sync. Any over/under prediction shows as
// slight gap between messages and composer much less visible than
// the old keyboard-composer gap.
let rawEased = eased
if animTickCount > 1 {
let velocity = eased - previousMonotonicEased
if velocity > 0 {
eased = min(eased + velocity, 1.0)
}
}
previousMonotonicEased = rawEased
if timeComplete || animTickCount > 30 {
let raw = animStartPadding + (animTargetPadding - animStartPadding) * eased
// Smooth glide: once past animDuration, blend sync-view-driven value
// with exponential approach to target. Each tick closes 40% of remaining
// gap converges smoothly instead of snapping 6-16pt at the deadline.
let remaining = abs(animTargetPadding - raw)
let pastDuration = elapsed > animDuration
let effectiveRaw: CGFloat
if pastDuration && remaining > 1 {
// Blend: 40% toward target each tick (exponential decay, ~3 ticks to <1pt)
let current = keyboardPadding
let step = (animTargetPadding - current) * 0.4
effectiveRaw = current + step
} else {
effectiveRaw = raw
}
let rounded = max(0, round(effectiveRaw))
// Hard deadline: abort after 500ms or 40 ticks (safety net).
let hardDeadline = elapsed >= animDuration + 0.25
let closeEnough = abs(animTargetPadding - rounded) <= 1
if hardDeadline || animTickCount > 40 || (pastDuration && closeEnough) {
let prevPadding = keyboardPadding
keyboardPadding = max(0, animTargetPadding)
// Pause instead of invalidate preserves vsync phase for next animation.
@@ -364,19 +457,20 @@ final class KeyboardTracker: ObservableObject {
lastTickTime = 0
isAnimatingKeyboard = false
#if DEBUG
let syncFinalOpacity = syncView?.layer.presentation().map { CGFloat($0.opacity) }
let elapsedMs = elapsed * 1000
print("⌨️ ✅ DONE | ticks=\(animTickCount) | final=\(Int(animTargetPadding)) | lastDelta=\(Int(animTargetPadding - prevPadding))pt | elapsed=\(String(format: "%.0f", elapsedMs))ms")
let snapDelta = Int(animTargetPadding - prevPadding)
print("⌨️ ✅ DONE | ticks=\(animTickCount) snap=\(snapDelta)pt | syncOp=\(syncFinalOpacity.map { String(format: "%.3f", $0) } ?? "nil") eased=\(String(format: "%.4f", eased)) | glide=\(pastDuration) elapsed=\(String(format: "%.0f", elapsedMs))ms dur=\(String(format: "%.0f", animDuration * 1000))ms")
#endif
} else if rounded != keyboardPadding {
#if DEBUG
let syncOpacity = syncView?.layer.presentation().map { CGFloat($0.opacity) }
let prevPad = keyboardPadding
let gliding = pastDuration && remaining > 1
#endif
keyboardPadding = rounded
#if DEBUG
if animTickCount <= 5 {
let delta = Int(rounded - prevPad)
print("⌨️ TICK #\(animTickCount) | eased=\(String(format: "%.3f", eased)) | pad=\(Int(rounded)) | delta=\(delta)pt | elapsed=\(String(format: "%.1f", elapsed * 1000))ms")
}
print("⌨️ T\(animTickCount) | syncOp=\(syncOpacity.map { String(format: "%.3f", $0) } ?? "nil") eased=\(String(format: "%.4f", eased)) | pad \(Int(prevPad))\(Int(rounded)) Δ\(Int(rounded - prevPad))pt | \(String(format: "%.1f", elapsed * 1000))ms\(gliding ? " 🛬" : "")")
#endif
}
}

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).