Фикс: дубликат CallKit вызова, disconnect recovery, WebRTC packet buffering и E2EE rebind loop
This commit is contained in:
@@ -41,84 +41,87 @@ struct ForwardChatPickerView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// MARK: - Header
|
||||
ForwardPickerHeader(
|
||||
isMultiSelect: isMultiSelect,
|
||||
onClose: { dismiss() },
|
||||
onSelect: {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isMultiSelect = true
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
// MARK: - Header
|
||||
ForwardPickerHeader(
|
||||
isMultiSelect: isMultiSelect,
|
||||
onClose: { dismiss() },
|
||||
onSelect: {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isMultiSelect = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// MARK: - Search
|
||||
ForwardPickerSearchBar(searchText: $searchText)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
// MARK: - Search
|
||||
ForwardPickerSearchBar(searchText: $searchText)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
|
||||
// MARK: - Chat List
|
||||
if dialogs.isEmpty && !searchText.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("No chats found")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color(white: 0.5))
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(dialogs.enumerated()), id: \.element.id) { index, dialog in
|
||||
ForwardPickerRow(
|
||||
dialog: dialog,
|
||||
isMultiSelect: isMultiSelect,
|
||||
isSelected: selectedIds.contains(dialog.opponentKey)
|
||||
) {
|
||||
if isMultiSelect {
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
if selectedIds.contains(dialog.opponentKey) {
|
||||
selectedIds.remove(dialog.opponentKey)
|
||||
} else {
|
||||
selectedIds.insert(dialog.opponentKey)
|
||||
// MARK: - Chat List
|
||||
if dialogs.isEmpty && !searchText.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("No chats found")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color(white: 0.5))
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(dialogs.enumerated()), id: \.element.id) { index, dialog in
|
||||
ForwardPickerRow(
|
||||
dialog: dialog,
|
||||
isMultiSelect: isMultiSelect,
|
||||
isSelected: selectedIds.contains(dialog.opponentKey)
|
||||
) {
|
||||
if isMultiSelect {
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
if selectedIds.contains(dialog.opponentKey) {
|
||||
selectedIds.remove(dialog.opponentKey)
|
||||
} else {
|
||||
selectedIds.insert(dialog.opponentKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onSelect([ChatRoute(dialog: dialog)])
|
||||
}
|
||||
} else {
|
||||
onSelect([ChatRoute(dialog: dialog)])
|
||||
}
|
||||
}
|
||||
|
||||
if index < dialogs.count - 1 {
|
||||
Divider()
|
||||
.padding(.leading, 65)
|
||||
.foregroundStyle(Color(red: 0x54 / 255.0, green: 0x54 / 255.0, blue: 0x58 / 255.0).opacity(0.55))
|
||||
if index < dialogs.count - 1 {
|
||||
Divider()
|
||||
.padding(.leading, 65)
|
||||
.foregroundStyle(Color(red: 0x54 / 255.0, green: 0x54 / 255.0, blue: 0x58 / 255.0).opacity(0.55))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
|
||||
// MARK: - Bottom Bar (multi-select)
|
||||
if isMultiSelect {
|
||||
ForwardPickerBottomBar(
|
||||
selectedCount: selectedIds.count,
|
||||
onSend: {
|
||||
let routes = dialogs
|
||||
.filter { selectedIds.contains($0.opponentKey) }
|
||||
.map { ChatRoute(dialog: $0) }
|
||||
if !routes.isEmpty { onSelect(routes) }
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
// MARK: - Bottom Bar (multi-select)
|
||||
if isMultiSelect {
|
||||
ForwardPickerBottomBar(
|
||||
selectedCount: selectedIds.count,
|
||||
onSend: {
|
||||
let routes = dialogs
|
||||
.filter { selectedIds.contains($0.opponentKey) }
|
||||
.map { ChatRoute(dialog: $0) }
|
||||
if !routes.isEmpty { onSelect(routes) }
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
.preferredColorScheme(.dark)
|
||||
.presentationBackground(Color.black)
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,9 +208,17 @@ private struct ForwardPickerHeader: View {
|
||||
if !isMultiSelect {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Select", action: onSelect)
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
Button(action: onSelect) {
|
||||
Text("Select")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 30)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(Color(white: 0.16))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,7 +266,7 @@ private struct ForwardPickerSearchBar: View {
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(Color(white: 0.14))
|
||||
.fill(Color.white.opacity(0.1))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,7 +286,7 @@ private struct ForwardPickerRow: View {
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle)
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -361,7 +372,7 @@ private struct ForwardPickerBottomBar: View {
|
||||
.frame(height: 42)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
.fill(Color(white: 0.14))
|
||||
.fill(Color.white.opacity(0.1))
|
||||
}
|
||||
|
||||
// Telegram send button: 33pt circle + SVG arrow
|
||||
|
||||
@@ -72,10 +72,9 @@ final class ImageViewerPresenter {
|
||||
|
||||
// MARK: - ImageGalleryViewer
|
||||
|
||||
/// Telegram-style multi-photo gallery viewer with hero transition animation.
|
||||
/// Reference: PhotosTransition/Helpers/PhotoGridView.swift — hero expand/collapse pattern.
|
||||
/// Android parity: `ImageViewerScreen.kt` — top bar with sender/date,
|
||||
/// bottom caption bar, edge-tap navigation, velocity dismiss, share/save.
|
||||
/// Multi-photo gallery viewer with hero transition animation.
|
||||
/// Adapted 1:1 from PhotosTransition/Helpers/PhotoGridView.swift — DetailPhotosView.
|
||||
/// Hero positioning is per-page INSIDE ForEach (not on TabView).
|
||||
struct ImageGalleryViewer: View {
|
||||
|
||||
let state: ImageViewerState
|
||||
@@ -85,11 +84,8 @@ struct ImageGalleryViewer: View {
|
||||
@State private var showControls = true
|
||||
@State private var currentZoomScale: CGFloat = 1.0
|
||||
@State private var isDismissing = false
|
||||
/// Hero transition state: false = positioned at source frame, true = fullscreen.
|
||||
@State private var isExpanded: Bool = false
|
||||
/// Drag offset for interactive pan-to-dismiss.
|
||||
@State private var dragOffset: CGSize = .zero
|
||||
/// Full screen dimensions (captured from geometry).
|
||||
@State private var viewSize: CGSize = UIScreen.main.bounds.size
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
@@ -108,95 +104,69 @@ struct ImageGalleryViewer: View {
|
||||
state.images.indices.contains(currentPage) ? state.images[currentPage] : nil
|
||||
}
|
||||
|
||||
/// Whether the source frame is valid for hero animation (non-zero).
|
||||
private var hasHeroSource: Bool {
|
||||
state.sourceFrame.width > 0 && state.sourceFrame.height > 0
|
||||
}
|
||||
|
||||
/// Hero animation spring — matches PhotosTransition reference.
|
||||
private var heroAnimation: Animation {
|
||||
.interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0)
|
||||
}
|
||||
|
||||
/// Opacity that decreases as user drags further from center.
|
||||
private var interactiveOpacity: CGFloat {
|
||||
let opacityY = abs(dragOffset.height) / (viewSize.height * 0.3)
|
||||
return max(1 - opacityY, 0)
|
||||
return isExpanded ? max(1 - opacityY, 0) : 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let sourceFrame = state.sourceFrame
|
||||
|
||||
ZStack {
|
||||
// Background — fades with hero expansion and drag progress
|
||||
Color.black
|
||||
.opacity(isExpanded ? interactiveOpacity : 0)
|
||||
// Hero positioning per-page inside ForEach — matches reference exactly
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
|
||||
ZoomableImagePage(
|
||||
attachmentId: info.attachmentId,
|
||||
onDismiss: { dismissAction() },
|
||||
showControls: $showControls,
|
||||
currentScale: $currentZoomScale,
|
||||
onEdgeTap: { direction in navigateEdgeTap(direction: direction) }
|
||||
)
|
||||
.frame(
|
||||
width: isExpanded ? viewSize.width : sourceFrame.width,
|
||||
height: isExpanded ? viewSize.height : sourceFrame.height
|
||||
)
|
||||
.clipped()
|
||||
.offset(
|
||||
x: isExpanded ? 0 : sourceFrame.minX,
|
||||
y: isExpanded ? 0 : sourceFrame.minY
|
||||
)
|
||||
.offset(dragOffset)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: isExpanded ? .center : .topLeading
|
||||
)
|
||||
.tag(index)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Pager with hero positioning
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
|
||||
ZoomableImagePage(
|
||||
attachmentId: info.attachmentId,
|
||||
onDismiss: { dismiss() },
|
||||
showControls: $showControls,
|
||||
currentScale: $currentZoomScale,
|
||||
onEdgeTap: { direction in
|
||||
navigateEdgeTap(direction: direction)
|
||||
}
|
||||
)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
|
||||
// Hero frame: source rect when collapsed, full screen when expanded
|
||||
.frame(
|
||||
width: isExpanded ? viewSize.width : (hasHeroSource ? sourceFrame.width : viewSize.width),
|
||||
height: isExpanded ? viewSize.height : (hasHeroSource ? sourceFrame.height : viewSize.height)
|
||||
)
|
||||
.clipped()
|
||||
.offset(
|
||||
x: isExpanded ? 0 : (hasHeroSource ? sourceFrame.minX : 0),
|
||||
y: isExpanded ? 0 : (hasHeroSource ? sourceFrame.minY : 0)
|
||||
)
|
||||
.offset(dragOffset)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: isExpanded ? .center : (hasHeroSource ? .topLeading : .center)
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
// Interactive drag gesture for hero dismiss (vertical only, when not zoomed)
|
||||
.simultaneousGesture(
|
||||
currentZoomScale <= 1.05 ?
|
||||
DragGesture(minimumDistance: 40)
|
||||
.onChanged { value in
|
||||
let dy = abs(value.translation.height)
|
||||
let dx = abs(value.translation.width)
|
||||
guard dy > dx * 2.0 else { return }
|
||||
dragOffset = .init(width: value.translation.width, height: value.translation.height)
|
||||
}
|
||||
.onEnded { value in
|
||||
if dragOffset.height > 50 {
|
||||
heroDismiss()
|
||||
} else {
|
||||
withAnimation(heroAnimation.speed(1.2)) {
|
||||
dragOffset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
: nil
|
||||
)
|
||||
|
||||
// Controls overlay — fades with hero expansion
|
||||
controlsOverlay
|
||||
.opacity(isExpanded ? 1 : 0)
|
||||
.opacity(interactiveOpacity)
|
||||
}
|
||||
.statusBarHidden(true)
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.ignoresSafeArea()
|
||||
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
|
||||
.contentShape(Rectangle())
|
||||
.overlay {
|
||||
// Pan gesture overlay — UIKit gesture for iOS 17+ compat
|
||||
HeroPanGestureOverlay { gesture in
|
||||
handlePanGesture(gesture)
|
||||
}
|
||||
.allowsHitTesting(isExpanded && currentZoomScale <= 1.05)
|
||||
}
|
||||
.overlay {
|
||||
overlayActions
|
||||
}
|
||||
.background {
|
||||
Color.black
|
||||
.opacity(interactiveOpacity)
|
||||
.opacity(isExpanded ? 1 : 0)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.allowsHitTesting(isExpanded)
|
||||
.onGeometryChange(for: CGSize.self, of: { $0.size }) { viewSize = $0 }
|
||||
.statusBarHidden(true)
|
||||
.task {
|
||||
prefetchAdjacentImages(around: state.initialIndex)
|
||||
guard !isExpanded else { return }
|
||||
@@ -209,101 +179,95 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Controls Overlay
|
||||
// MARK: - Pan Gesture
|
||||
|
||||
private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
|
||||
let panState = gesture.state
|
||||
let translation = gesture.translation(in: gesture.view)
|
||||
|
||||
if panState == .began || panState == .changed {
|
||||
dragOffset = .init(width: translation.x, height: translation.y)
|
||||
} else {
|
||||
if dragOffset.height > 50 {
|
||||
heroDismiss()
|
||||
} else {
|
||||
withAnimation(heroAnimation.speed(1.2)) {
|
||||
dragOffset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overlay Actions (matches PhotosTransition/ContentView.swift OverlayActionView)
|
||||
|
||||
@ViewBuilder
|
||||
private var controlsOverlay: some View {
|
||||
VStack(spacing: 0) {
|
||||
if showControls && !isDismissing {
|
||||
topBar
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
Spacer()
|
||||
if showControls && !isDismissing {
|
||||
bottomBar
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.easeOut(duration: 0.2), value: showControls)
|
||||
}
|
||||
private var overlayActions: some View {
|
||||
let overlayOpacity: CGFloat = 1 - min(abs(dragOffset.height / 30), 1)
|
||||
|
||||
// MARK: - Top Bar
|
||||
if showControls && !isDismissing && isExpanded {
|
||||
VStack {
|
||||
// Top actions
|
||||
HStack {
|
||||
glassButton(systemName: "chevron.left") { dismissAction() }
|
||||
|
||||
private var topBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if let info = currentInfo {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(info.senderName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(Self.dateFormatter.string(from: info.timestamp))
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
if state.images.count > 1 {
|
||||
glassLabel("\(currentPage + 1) / \(state.images.count)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if state.images.count > 1 {
|
||||
Text("\(currentPage + 1) / \(state.images.count)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .top))
|
||||
}
|
||||
|
||||
// MARK: - Bottom Bar
|
||||
|
||||
private var bottomBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let caption = currentInfo?.caption, !caption.isEmpty {
|
||||
Text(caption)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.black.opacity(0.5))
|
||||
}
|
||||
|
||||
HStack(spacing: 32) {
|
||||
Button { shareCurrentImage() } label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay {
|
||||
if let info = currentInfo {
|
||||
glassLabel(info.senderName)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button { saveCurrentImage() } label: {
|
||||
Image(systemName: "square.and.arrow.down")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
// Bottom actions
|
||||
HStack {
|
||||
glassButton(systemName: "square.and.arrow.up.fill") { shareCurrentImage() }
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
glassButton(systemName: "square.and.arrow.down") { saveCurrentImage() }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 8)
|
||||
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .bottom))
|
||||
.padding(.horizontal, 15)
|
||||
.compositingGroup()
|
||||
.opacity(overlayOpacity)
|
||||
.environment(\.colorScheme, .dark)
|
||||
.transition(.opacity)
|
||||
.animation(.easeOut(duration: 0.2), value: showControls)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Edge Tap Navigation
|
||||
// MARK: - Glass Button / Label helpers
|
||||
|
||||
private func glassButton(systemName: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.background { TelegramGlassCircle() }
|
||||
}
|
||||
|
||||
private func glassLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.vertical, 10)
|
||||
.background { TelegramGlassCapsule() }
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
private func navigateEdgeTap(direction: Int) {
|
||||
let target = currentPage + direction
|
||||
@@ -313,8 +277,7 @@ struct ImageGalleryViewer: View {
|
||||
|
||||
// MARK: - Dismiss
|
||||
|
||||
/// Unified dismiss: hero collapse when not zoomed, fade when zoomed.
|
||||
private func dismiss() {
|
||||
private func dismissAction() {
|
||||
if currentZoomScale > 1.05 {
|
||||
fadeDismiss()
|
||||
} else {
|
||||
@@ -322,7 +285,6 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hero collapse back to source frame.
|
||||
private func heroDismiss() {
|
||||
guard !isDismissing else { return }
|
||||
isDismissing = true
|
||||
@@ -337,7 +299,6 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback fade dismiss when zoomed.
|
||||
private func fadeDismiss() {
|
||||
guard !isDismissing else { return }
|
||||
isDismissing = true
|
||||
@@ -402,3 +363,69 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HeroPanGestureOverlay
|
||||
|
||||
/// Transparent UIView overlay with UIPanGestureRecognizer for vertical hero dismiss.
|
||||
/// Uses UIKit gesture (not UIGestureRecognizerRepresentable) for iOS 17+ compat.
|
||||
/// Matches PanGesture from PhotosTransition reference — vertical only, single touch.
|
||||
private struct HeroPanGestureOverlay: UIViewRepresentable {
|
||||
var onPan: (UIPanGestureRecognizer) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
|
||||
let pan = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePan(_:)))
|
||||
pan.minimumNumberOfTouches = 1
|
||||
pan.maximumNumberOfTouches = 1
|
||||
pan.delegate = context.coordinator
|
||||
view.addGestureRecognizer(pan)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
context.coordinator.onPan = onPan
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(onPan: onPan) }
|
||||
|
||||
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
|
||||
var onPan: (UIPanGestureRecognizer) -> Void
|
||||
|
||||
init(onPan: @escaping (UIPanGestureRecognizer) -> Void) {
|
||||
self.onPan = onPan
|
||||
}
|
||||
|
||||
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
onPan(gesture)
|
||||
}
|
||||
|
||||
// Only begin for downward vertical drags
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
|
||||
let velocity = pan.velocity(in: pan.view)
|
||||
return velocity.y > abs(velocity.x)
|
||||
}
|
||||
|
||||
// Let TabView scroll pass through when scrolled down
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
if let scrollView = otherGestureRecognizer.view as? UIScrollView {
|
||||
return scrollView.contentOffset.y <= 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow simultaneous recognition with other gestures (taps, pinch, etc.)
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
return !(otherGestureRecognizer is UIPanGestureRecognizer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user