Фикс: просмотр фото — убрана нерабочая hero-анимация, фотки теперь заполняют экран

This commit is contained in:
2026-04-07 19:09:40 +05:00
parent d84c867bd3
commit 62c24d19cf
5 changed files with 26 additions and 79 deletions

View File

@@ -203,7 +203,7 @@ extension MessageCellLayout {
} else { } else {
messageType = .text messageType = .text
} }
// Emoji-only: 1-3 emoji without other text large font, no bubble // Emoji-only: single emoji without other text large font, no bubble
if messageType == .text && !config.text.isEmpty && EmojiParser.isEmojiOnly(config.text) { if messageType == .text && !config.text.isEmpty && EmojiParser.isEmojiOnly(config.text) {
messageType = .emojiOnly messageType = .emojiOnly
} }

View File

@@ -277,6 +277,9 @@ extension CallManager {
didReceiveCallKitDeactivation = false didReceiveCallKitDeactivation = false
var finalState = CallUiState() var finalState = CallUiState()
finalState.peerPublicKey = uiState.peerPublicKey
finalState.peerTitle = uiState.peerTitle
finalState.peerUsername = uiState.peerUsername
if let reason, !reason.isEmpty { if let reason, !reason.isEmpty {
finalState.statusText = reason finalState.statusText = reason
} }

View File

@@ -68,15 +68,14 @@ enum EmojiParser {
return ":emoji_\(codes.joined(separator: "-")):" return ":emoji_\(codes.joined(separator: "-")):"
} }
// MARK: - Emoji-Only Detection (Telegram Parity) // MARK: - Emoji-Only Detection
/// Returns true if text contains ONLY emoji characters (no text, no limit on count). /// Returns true if text is exactly ONE emoji character (enlarged display).
/// Telegram parity: `ChatMessageItemCommon.swift:243-252` no max count, /// Multiple emoji use regular text size.
/// just `message.text.containsOnlyEmoji` (EmojiUtils.swift:77-78).
static func isEmojiOnly(_ text: String) -> Bool { static func isEmojiOnly(_ text: String) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false } guard trimmed.count == 1 else { return false }
return trimmed.allSatisfy { $0.isEmojiCharacter } return trimmed.first!.isEmojiCharacter
} }
} }

View File

@@ -162,10 +162,6 @@ struct ImageGalleryViewer: View {
state.images.indices.contains(currentPage) ? state.images[currentPage] : nil state.images.indices.contains(currentPage) ? state.images[currentPage] : nil
} }
private var heroAnimation: Animation {
.interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0)
}
private var backgroundOpacity: CGFloat { private var backgroundOpacity: CGFloat {
let progress = min(abs(panCoordinator.dragOffset.height) / 80, 1) let progress = min(abs(panCoordinator.dragOffset.height) / 80, 1)
return isExpanded ? max(1 - progress, 0) : 0 return isExpanded ? max(1 - progress, 0) : 0
@@ -184,13 +180,8 @@ struct ImageGalleryViewer: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
let sf = state.sourceFrame
TabView(selection: $currentPage) { TabView(selection: $currentPage) {
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
let isHeroPage = index == state.initialIndex
let heroActive = isHeroPage && !isExpanded
ZoomableImagePage( ZoomableImagePage(
attachmentId: info.attachmentId, attachmentId: info.attachmentId,
onDismiss: { dismissAction() }, onDismiss: { dismissAction() },
@@ -198,14 +189,7 @@ struct ImageGalleryViewer: View {
currentScale: $currentZoomScale, currentScale: $currentZoomScale,
onEdgeTap: { dir in navigateEdgeTap(direction: dir) } onEdgeTap: { dir in navigateEdgeTap(direction: dir) }
) )
// Hero page: animate from sourceFrame fullscreen. .offset(panCoordinator.dragOffset)
// Non-hero pages: NO explicit frame fill TabView page naturally.
.modifier(GalleryPageModifier(
heroActive: heroActive,
sourceFrame: sf,
fullSize: screenSize,
dragOffset: panCoordinator.dragOffset
))
.tag(index) .tag(index)
} }
} }
@@ -222,7 +206,7 @@ struct ImageGalleryViewer: View {
.task { .task {
prefetchAdjacentImages(around: state.initialIndex) prefetchAdjacentImages(around: state.initialIndex)
guard !isExpanded else { return } guard !isExpanded else { return }
withAnimation(heroAnimation) { isExpanded = true } withAnimation(.easeOut(duration: 0.2)) { isExpanded = true }
} }
.onChange(of: currentPage) { _, p in prefetchAdjacentImages(around: p) } .onChange(of: currentPage) { _, p in prefetchAdjacentImages(around: p) }
.onChange(of: currentZoomScale) { _, s in panCoordinator.isEnabled = s <= 1.05 } .onChange(of: currentZoomScale) { _, s in panCoordinator.isEnabled = s <= 1.05 }
@@ -237,7 +221,7 @@ struct ImageGalleryViewer: View {
if y > 50 || v > 1000 { if y > 50 || v > 1000 {
dismissAction() dismissAction()
} else { } else {
withAnimation(heroAnimation.speed(1.2)) { withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
panCoordinator.dragOffset = .zero panCoordinator.dragOffset = .zero
} }
} }
@@ -373,25 +357,7 @@ struct ImageGalleryViewer: View {
// MARK: - Dismiss // MARK: - Dismiss
private func dismissAction() { private func dismissAction() {
if currentZoomScale > 1.05 || currentPage != state.initialIndex { fadeDismiss()
fadeDismiss()
} else {
heroDismiss()
}
}
private func heroDismiss() {
guard !isDismissing else { return }
isDismissing = true
panCoordinator.isEnabled = false
Task {
withAnimation(heroAnimation.speed(1.2)) {
panCoordinator.dragOffset = .zero
isExpanded = false
}
try? await Task.sleep(for: .seconds(0.35))
onDismiss()
}
} }
private func fadeDismiss() { private func fadeDismiss() {
@@ -448,30 +414,3 @@ struct ImageGalleryViewer: View {
} }
} }
// MARK: - GalleryPageModifier
/// Applies hero transition frame/offset for the initial page.
/// Uses a SINGLE view branch (no if/else) to preserve SwiftUI structural identity
/// across the hero expanded transition. This prevents GeometryReader inside
/// ZoomableImagePage from receiving stale/incorrect proposed sizes during the swap.
private struct GalleryPageModifier: ViewModifier {
let heroActive: Bool
let sourceFrame: CGRect
let fullSize: CGSize
let dragOffset: CGSize
func body(content: Content) -> some View {
content
.frame(
width: heroActive ? sourceFrame.width : fullSize.width,
height: heroActive ? sourceFrame.height : fullSize.height
)
.clipped()
.offset(
x: heroActive ? sourceFrame.minX : 0,
y: heroActive ? sourceFrame.minY : 0
)
.offset(dragOffset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}

View File

@@ -4,8 +4,9 @@ import UIKit
// MARK: - ZoomableImagePage // MARK: - ZoomableImagePage
/// Single page in the image gallery viewer. /// Single page in the image gallery viewer.
/// Uses GeometryReader + explicit frame calculation (Telegram parity) instead of /// Uses screen bounds for image sizing (Telegram parity) GeometryReader is unreliable
/// `.scaledToFit()` which is unreliable inside TabView `.page` style overlay chains. /// inside TabView `.page` style, especially during hero animation transitions.
/// GeometryReader is kept only for centering via `.position()`.
struct ZoomableImagePage: View { struct ZoomableImagePage: View {
let attachmentId: String let attachmentId: String
@@ -20,14 +21,20 @@ struct ZoomableImagePage: View {
@State private var zoomOffset: CGSize = .zero @State private var zoomOffset: CGSize = .zero
@GestureState private var pinchScale: CGFloat = 1.0 @GestureState private var pinchScale: CGFloat = 1.0
/// Fixed screen bounds for fittedSize calculation not dependent on GeometryReader.
/// Telegram uses the same approach: sizes are computed against screen dimensions,
/// UIScrollView handles the rest.
private let screenSize = UIScreen.main.bounds.size
var body: some View { var body: some View {
let effectiveScale = zoomScale * pinchScale let effectiveScale = zoomScale * pinchScale
GeometryReader { geo in GeometryReader { geo in
let viewSize = geo.size let centerX = geo.size.width / 2
let centerY = geo.size.height / 2
if let image { if let image {
let fitted = fittedSize(image.size, in: viewSize) let fitted = fittedSize(image.size, in: screenSize)
Image(uiImage: image) Image(uiImage: image)
.resizable() .resizable()
@@ -37,13 +44,12 @@ struct ZoomableImagePage: View {
x: effectiveScale > 1.05 ? zoomOffset.width : 0, x: effectiveScale > 1.05 ? zoomOffset.width : 0,
y: effectiveScale > 1.05 ? zoomOffset.height : 0 y: effectiveScale > 1.05 ? zoomOffset.height : 0
) )
.position(x: viewSize.width / 2, y: viewSize.height / 2) .position(x: centerX, y: centerY)
} else { } else {
placeholder placeholder
.position(x: viewSize.width / 2, y: viewSize.height / 2) .position(x: centerX, y: centerY)
} }
} }
.ignoresSafeArea()
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture(count: 2) { .onTapGesture(count: 2) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {