Фикс: просмотр фото — убрана нерабочая hero-анимация, фотки теперь заполняют экран
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user