0.10.6
This commit is contained in:
commit
92660a4ba0
228 files changed
+24496
No files matched your search
@@ -0,0 +1,363 @@
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
private typealias MediaViewerPlatformImage = UIImage
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
private typealias MediaViewerPlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
enum MediaViewerDismissGesturePolicy {
|
||||
private static let verticalDominance: CGFloat = 1.15
|
||||
|
||||
static func isVertical(_ translation: CGSize) -> Bool {
|
||||
let verticalDistance = abs(translation.height)
|
||||
let horizontalDistance = abs(translation.width)
|
||||
return verticalDistance >= 8 && verticalDistance > horizontalDistance * verticalDominance
|
||||
}
|
||||
|
||||
static func shouldDismiss(
|
||||
translation: CGSize,
|
||||
predictedEndTranslation: CGSize,
|
||||
containerHeight: CGFloat
|
||||
) -> Bool {
|
||||
guard isVertical(translation) else { return false }
|
||||
|
||||
let distanceThreshold = min(140, max(88, containerHeight * 0.14))
|
||||
if abs(translation.height) >= distanceThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
return isVertical(predictedEndTranslation)
|
||||
&& abs(predictedEndTranslation.height) >= distanceThreshold * 1.35
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct MediaViewer: View {
|
||||
let item: MediaViewerItem
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
@State private var isTrackingDismissGesture = false
|
||||
@State private var isCompletingDismissal = false
|
||||
@State private var isPhotoZoomed = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Color.black
|
||||
.opacity(backgroundOpacity(in: geometry.size.height))
|
||||
.ignoresSafeArea()
|
||||
|
||||
ZStack {
|
||||
viewerContent
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.72), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.offset(y: dragOffset)
|
||||
.scaleEffect(contentScale(in: geometry.size.height))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
verticalDismissGesture(in: geometry.size),
|
||||
including: .all
|
||||
)
|
||||
.onChange(of: isPhotoZoomed) { _, isZoomed in
|
||||
guard isZoomed else { return }
|
||||
resetDismissGesture(animated: true)
|
||||
}
|
||||
.accessibilityAction(.escape) {
|
||||
closeImmediately()
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.onExitCommand {
|
||||
closeImmediately()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var canTrackDismissGesture: Bool {
|
||||
!isPhotoZoomed && !isCompletingDismissal
|
||||
}
|
||||
|
||||
private func verticalDismissGesture(in size: CGSize) -> some Gesture {
|
||||
DragGesture(minimumDistance: 8, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
guard canTrackDismissGesture else { return }
|
||||
|
||||
if !isTrackingDismissGesture {
|
||||
guard MediaViewerDismissGesturePolicy.isVertical(value.translation) else { return }
|
||||
isTrackingDismissGesture = true
|
||||
}
|
||||
|
||||
dragOffset = value.translation.height
|
||||
}
|
||||
.onEnded { value in
|
||||
guard isTrackingDismissGesture else { return }
|
||||
isTrackingDismissGesture = false
|
||||
|
||||
guard canTrackDismissGesture else {
|
||||
resetDismissGesture(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if MediaViewerDismissGesturePolicy.shouldDismiss(
|
||||
translation: value.translation,
|
||||
predictedEndTranslation: value.predictedEndTranslation,
|
||||
containerHeight: size.height
|
||||
) {
|
||||
completeDismissal(
|
||||
direction: dismissalDirection(for: value),
|
||||
containerHeight: size.height
|
||||
)
|
||||
} else {
|
||||
resetDismissGesture(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissalDirection(for value: DragGesture.Value) -> CGFloat {
|
||||
let projected = value.predictedEndTranslation.height
|
||||
let distance = abs(projected) > abs(value.translation.height)
|
||||
? projected
|
||||
: value.translation.height
|
||||
return distance < 0 ? -1 : 1
|
||||
}
|
||||
|
||||
private func completeDismissal(direction: CGFloat, containerHeight: CGFloat) {
|
||||
guard !isCompletingDismissal else { return }
|
||||
isCompletingDismissal = true
|
||||
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
dragOffset = direction * max(700, containerHeight * 1.15)
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
private func resetDismissGesture(animated: Bool) {
|
||||
isTrackingDismissGesture = false
|
||||
if animated {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.84)) {
|
||||
dragOffset = 0
|
||||
}
|
||||
} else {
|
||||
dragOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func closeImmediately() {
|
||||
guard !isCompletingDismissal else { return }
|
||||
isCompletingDismissal = true
|
||||
onClose()
|
||||
}
|
||||
|
||||
private func dismissalProgress(in containerHeight: CGFloat) -> CGFloat {
|
||||
min(1, abs(dragOffset) / max(1, containerHeight * 0.62))
|
||||
}
|
||||
|
||||
private func backgroundOpacity(in containerHeight: CGFloat) -> Double {
|
||||
Double(1 - dismissalProgress(in: containerHeight) * 0.72)
|
||||
}
|
||||
|
||||
private func contentScale(in containerHeight: CGFloat) -> CGFloat {
|
||||
1 - dismissalProgress(in: containerHeight) * 0.045
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var viewerContent: some View {
|
||||
switch item.kind {
|
||||
case .photo:
|
||||
ZoomablePhotoView(url: item.url, isZoomed: $isPhotoZoomed)
|
||||
case .video:
|
||||
FullWindowVideoPlayer(url: item.url)
|
||||
default:
|
||||
ContentUnavailableView(
|
||||
"Не удалось открыть медиа",
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct FullWindowVideoPlayer: View {
|
||||
let url: URL
|
||||
@State private var player: AVPlayer?
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VideoPlayer(player: player)
|
||||
.task(id: url) {
|
||||
player?.pause()
|
||||
let nextPlayer = AVPlayer(url: url)
|
||||
player = nextPlayer
|
||||
nextPlayer.play()
|
||||
}
|
||||
.onDisappear {
|
||||
player?.pause()
|
||||
player = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct ZoomablePhotoView: View {
|
||||
let url: URL
|
||||
@Binding var isZoomed: Bool
|
||||
|
||||
@State private var imageValue: MediaViewerPlatformImage?
|
||||
@State private var hasFinishedLoading = false
|
||||
@State private var scale: CGFloat = 1
|
||||
@State private var offset: CGSize = .zero
|
||||
@GestureState private var gestureScale: CGFloat = 1
|
||||
@GestureState private var gestureOffset: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Color.black
|
||||
|
||||
if let imageValue {
|
||||
image(imageValue)
|
||||
.scaledToFit()
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.scaleEffect(effectiveScale)
|
||||
.offset(effectiveOffset)
|
||||
.gesture(dragGesture)
|
||||
.simultaneousGesture(magnifyGesture)
|
||||
.onTapGesture(count: 2, perform: toggleZoom)
|
||||
} else if hasFinishedLoading {
|
||||
invalidImage
|
||||
} else {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
.onChange(of: effectiveScale) { _, value in
|
||||
isZoomed = value > 1.01
|
||||
}
|
||||
.onDisappear {
|
||||
isZoomed = false
|
||||
}
|
||||
.task(id: url) {
|
||||
imageValue = nil
|
||||
hasFinishedLoading = false
|
||||
let data = await Task.detached(priority: .userInitiated) {
|
||||
try? Data(contentsOf: url, options: [.mappedIfSafe])
|
||||
}.value
|
||||
guard !Task.isCancelled else { return }
|
||||
imageValue = data.flatMap { MediaViewerPlatformImage(data: $0) }
|
||||
hasFinishedLoading = true
|
||||
}
|
||||
}
|
||||
|
||||
private var effectiveScale: CGFloat {
|
||||
min(5, max(1, scale * gestureScale))
|
||||
}
|
||||
|
||||
private var effectiveOffset: CGSize {
|
||||
guard effectiveScale > 1 else { return .zero }
|
||||
return CGSize(
|
||||
width: offset.width + gestureOffset.width,
|
||||
height: offset.height + gestureOffset.height
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func image(_ value: MediaViewerPlatformImage) -> some View {
|
||||
#if os(iOS)
|
||||
Image(uiImage: value)
|
||||
.resizable()
|
||||
#elseif os(macOS)
|
||||
Image(nsImage: value)
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var invalidImage: some View {
|
||||
ContentUnavailableView(
|
||||
"Фото повреждено",
|
||||
systemImage: "photo.badge.exclamationmark"
|
||||
)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture()
|
||||
.updating($gestureOffset) { value, state, _ in
|
||||
guard effectiveScale > 1 else { return }
|
||||
state = value.translation
|
||||
}
|
||||
.onEnded { value in
|
||||
guard effectiveScale > 1 else {
|
||||
offset = .zero
|
||||
return
|
||||
}
|
||||
offset.width += value.translation.width
|
||||
offset.height += value.translation.height
|
||||
}
|
||||
}
|
||||
|
||||
private var magnifyGesture: some Gesture {
|
||||
MagnifyGesture()
|
||||
.updating($gestureScale) { value, state, _ in
|
||||
state = value.magnification
|
||||
}
|
||||
.onEnded { value in
|
||||
scale = min(5, max(1, scale * value.magnification))
|
||||
if scale == 1 {
|
||||
offset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleZoom() {
|
||||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
||||
if scale > 1 {
|
||||
scale = 1
|
||||
offset = .zero
|
||||
} else {
|
||||
scale = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user