2023-01-13 21:11:30 +01:00
|
|
|
//
|
|
|
|
// https://mczachurski.dev
|
|
|
|
// Copyright © 2023 Marcin Czachurski and the repository contributors.
|
|
|
|
// Licensed under the MIT License.
|
|
|
|
//
|
|
|
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
struct ImagesViewer: View {
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
|
2023-02-20 15:21:39 +01:00
|
|
|
private let statusViewModel: StatusModel
|
|
|
|
private let selectedAttachmentId: String
|
|
|
|
private let image: Image
|
|
|
|
private let closeDragDistance = UIScreen.main.bounds.height / 1.8
|
|
|
|
|
2023-01-13 21:11:30 +01:00
|
|
|
// Magnification.
|
2023-02-20 10:57:16 +01:00
|
|
|
@State private var currentMagnification = 0.0
|
|
|
|
@State private var finalMagnification = 1.0
|
|
|
|
|
|
|
|
// Rotation.
|
|
|
|
@State private var rotationAngle = Angle.zero
|
2023-01-13 21:11:30 +01:00
|
|
|
|
|
|
|
// Draging.
|
|
|
|
@State private var currentOffset = CGSize.zero
|
2023-01-14 11:57:28 +01:00
|
|
|
@State private var accumulatedOffset = CGSize.zero
|
2023-01-13 21:11:30 +01:00
|
|
|
|
2023-02-20 14:25:46 +01:00
|
|
|
init(statusViewModel: StatusModel, selectedAttachmentId: String) {
|
|
|
|
self.statusViewModel = statusViewModel
|
|
|
|
self.selectedAttachmentId = selectedAttachmentId
|
|
|
|
|
|
|
|
if let attachment = statusViewModel.mediaAttachments.first(where: { $0.id == selectedAttachmentId }),
|
2023-02-20 10:57:16 +01:00
|
|
|
let data = attachment.data,
|
2023-02-20 14:25:46 +01:00
|
|
|
let uiImage = UIImage(data: data) {
|
|
|
|
self.image = Image(uiImage: uiImage)
|
|
|
|
} else {
|
2023-02-20 15:21:39 +01:00
|
|
|
self.image = Image(systemName: "photo")
|
2023-02-20 14:25:46 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var body: some View {
|
2023-02-20 15:21:39 +01:00
|
|
|
image
|
|
|
|
.resizable()
|
|
|
|
.aspectRatio(contentMode: .fit)
|
|
|
|
.tag(selectedAttachmentId)
|
|
|
|
.offset(currentOffset)
|
|
|
|
.rotationEffect(rotationAngle)
|
|
|
|
.scaleEffect(finalMagnification + currentMagnification)
|
|
|
|
.gesture(dragGesture)
|
|
|
|
.gesture(magnificationGesture)
|
|
|
|
.gesture(doubleTapGesture)
|
|
|
|
.gesture(tapGesture)
|
2023-01-13 21:11:30 +01:00
|
|
|
}
|
2023-02-20 15:21:39 +01:00
|
|
|
|
2023-02-20 10:57:16 +01:00
|
|
|
@MainActor
|
2023-01-13 21:11:30 +01:00
|
|
|
var magnificationGesture: some Gesture {
|
|
|
|
MagnificationGesture()
|
|
|
|
.onChanged { amount in
|
2023-02-20 15:21:39 +01:00
|
|
|
self.currentMagnification = (amount - 1) * self.finalMagnification
|
2023-01-13 21:11:30 +01:00
|
|
|
}
|
|
|
|
.onEnded { amount in
|
2023-02-20 15:21:39 +01:00
|
|
|
self.revertToPrecalculatedMagnification()
|
2023-01-13 21:11:30 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var doubleTapGesture: some Gesture {
|
|
|
|
TapGesture(count: 2)
|
|
|
|
.onEnded { _ in
|
2023-01-14 11:57:28 +01:00
|
|
|
withAnimation {
|
2023-02-20 10:57:16 +01:00
|
|
|
if self.finalMagnification == 1.0 {
|
2023-02-20 15:21:39 +01:00
|
|
|
self.currentOffset = CGSize.zero
|
|
|
|
self.accumulatedOffset = CGSize.zero
|
|
|
|
self.currentMagnification = 0
|
|
|
|
self.finalMagnification = 2.0
|
2023-02-20 10:57:16 +01:00
|
|
|
} else {
|
2023-02-20 15:21:39 +01:00
|
|
|
self.currentOffset = CGSize.zero
|
|
|
|
self.accumulatedOffset = CGSize.zero
|
|
|
|
self.currentMagnification = 0
|
|
|
|
self.finalMagnification = 1.0
|
2023-02-20 10:57:16 +01:00
|
|
|
}
|
2023-01-14 11:57:28 +01:00
|
|
|
}
|
2023-01-13 21:11:30 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-02-20 10:57:16 +01:00
|
|
|
@MainActor
|
2023-01-13 21:11:30 +01:00
|
|
|
var dragGesture: some Gesture {
|
2023-02-20 10:57:16 +01:00
|
|
|
DragGesture()
|
2023-01-13 21:11:30 +01:00
|
|
|
.onChanged { amount in
|
2023-02-20 10:57:16 +01:00
|
|
|
// Opacity and rotation is working only when we have small image size.
|
|
|
|
if self.finalMagnification == 1.0 {
|
|
|
|
// We can move image whatever we want.
|
|
|
|
self.currentOffset = CGSize(width: amount.translation.width + self.accumulatedOffset.width,
|
|
|
|
height: amount.translation.height + self.accumulatedOffset.height)
|
|
|
|
|
|
|
|
// Changing angle.
|
|
|
|
self.rotationAngle = Angle(degrees: Double(self.currentOffset.width / 30))
|
|
|
|
} else {
|
2023-02-20 11:55:38 +01:00
|
|
|
// Bigger images we can move only horizontally (we have to include magnifications).
|
2023-02-21 07:19:02 +01:00
|
|
|
let offsetWidth = (amount.predictedEndTranslation.width / self.finalMagnification) + self.accumulatedOffset.width
|
|
|
|
|
|
|
|
withAnimation(.spring()) {
|
|
|
|
self.currentOffset = CGSize(width: offsetWidth, height: 0)
|
|
|
|
}
|
2023-02-20 10:57:16 +01:00
|
|
|
}
|
2023-01-13 21:11:30 +01:00
|
|
|
} .onEnded { amount in
|
2023-02-20 15:21:39 +01:00
|
|
|
self.accumulatedOffset = CGSize(width: (amount.predictedEndTranslation.width / self.finalMagnification) + self.accumulatedOffset.width,
|
|
|
|
height: (amount.predictedEndTranslation.height / self.finalMagnification) + self.accumulatedOffset.height)
|
2023-01-14 20:45:49 +01:00
|
|
|
|
2023-02-20 10:57:16 +01:00
|
|
|
// Animations only for small images sizes,
|
|
|
|
if self.finalMagnification == 1.0 {
|
2023-02-20 15:21:39 +01:00
|
|
|
// When we still are in range visible image then we have to only revert back image to starting position..
|
|
|
|
if self.accumulatedOffset.height > -closeDragDistance && self.accumulatedOffset.height < closeDragDistance {
|
|
|
|
withAnimation(.linear(duration: 0.1)) {
|
|
|
|
self.currentOffset = self.accumulatedOffset
|
|
|
|
}
|
|
|
|
|
2023-02-20 10:57:16 +01:00
|
|
|
// Revert back image offset.
|
2023-02-20 15:21:39 +01:00
|
|
|
withAnimation(.linear(duration: 0.3).delay(0.1)) {
|
2023-02-20 10:57:16 +01:00
|
|
|
self.currentOffset = CGSize.zero
|
|
|
|
self.accumulatedOffset = CGSize.zero
|
2023-02-21 07:19:02 +01:00
|
|
|
self.rotationAngle = Angle.zero
|
2023-02-20 10:57:16 +01:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// Close the screen.
|
2023-02-20 15:21:39 +01:00
|
|
|
withAnimation(.linear(duration: 0.4)) {
|
|
|
|
// We have to set end translations for sure outside the screen.
|
|
|
|
self.currentOffset = CGSize(width: amount.predictedEndTranslation.width * 2, height: amount.predictedEndTranslation.height * 2)
|
|
|
|
self.rotationAngle = Angle(degrees: Double(amount.predictedEndTranslation.width / 30))
|
2023-02-20 10:57:16 +01:00
|
|
|
self.accumulatedOffset = CGSize.zero
|
|
|
|
}
|
|
|
|
|
2023-02-20 15:21:39 +01:00
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
|
|
|
withoutAnimation {
|
|
|
|
self.dismiss()
|
|
|
|
}
|
|
|
|
}
|
2023-01-14 20:45:49 +01:00
|
|
|
}
|
|
|
|
} else {
|
2023-02-20 10:57:16 +01:00
|
|
|
self.moveToEdge()
|
2023-01-14 20:45:49 +01:00
|
|
|
}
|
2023-01-13 21:11:30 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var tapGesture: some Gesture {
|
|
|
|
TapGesture().onEnded({ _ in
|
2023-02-20 15:21:39 +01:00
|
|
|
withoutAnimation {
|
|
|
|
self.dismiss()
|
|
|
|
}
|
2023-01-13 21:11:30 +01:00
|
|
|
})
|
|
|
|
}
|
2023-01-14 20:45:49 +01:00
|
|
|
|
2023-02-20 10:57:16 +01:00
|
|
|
@MainActor
|
2023-02-20 15:21:39 +01:00
|
|
|
private func revertToPrecalculatedMagnification() {
|
|
|
|
let magnification = self.finalMagnification + self.currentMagnification
|
|
|
|
|
2023-01-14 20:45:49 +01:00
|
|
|
if magnification < 1.0 {
|
|
|
|
// When image is small we are returning to starting point.
|
2023-02-20 14:25:46 +01:00
|
|
|
withAnimation {
|
2023-02-20 15:21:39 +01:00
|
|
|
self.finalMagnification = 1.0
|
|
|
|
self.currentMagnification = 0
|
2023-01-14 20:45:49 +01:00
|
|
|
|
|
|
|
// Also we have to move image to orginal position.
|
2023-02-20 15:21:39 +01:00
|
|
|
self.currentOffset = CGSize.zero
|
2023-01-14 20:45:49 +01:00
|
|
|
}
|
2023-02-20 10:57:16 +01:00
|
|
|
|
|
|
|
HapticService.shared.fireHaptic(of: .animation)
|
|
|
|
} else if magnification > 3.0 {
|
2023-01-14 20:45:49 +01:00
|
|
|
// When image is magnified to much we are rturning to 1.5 maginification.
|
2023-02-20 14:25:46 +01:00
|
|
|
withAnimation {
|
2023-02-20 15:21:39 +01:00
|
|
|
self.finalMagnification = 3.0
|
|
|
|
self.currentMagnification = 0
|
2023-01-14 20:45:49 +01:00
|
|
|
}
|
2023-02-20 10:57:16 +01:00
|
|
|
|
|
|
|
HapticService.shared.fireHaptic(of: .animation)
|
2023-01-14 20:45:49 +01:00
|
|
|
} else {
|
2023-02-20 15:21:39 +01:00
|
|
|
self.finalMagnification = magnification
|
|
|
|
self.currentMagnification = 0
|
2023-02-20 10:57:16 +01:00
|
|
|
|
2023-02-20 15:21:39 +01:00
|
|
|
// Verify if we have to move image to nearest edge.
|
2023-02-20 10:57:16 +01:00
|
|
|
self.moveToEdge()
|
2023-01-14 20:45:49 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-02-20 10:57:16 +01:00
|
|
|
@MainActor
|
|
|
|
private func moveToEdge() {
|
|
|
|
let maxEdgeDistance = ((UIScreen.main.bounds.width * self.finalMagnification) - UIScreen.main.bounds.width) / (2 * self.finalMagnification)
|
|
|
|
|
|
|
|
if self.currentOffset.width > maxEdgeDistance {
|
2023-02-20 15:21:39 +01:00
|
|
|
withAnimation(.linear(duration: 0.15)) {
|
|
|
|
self.currentOffset = CGSize(width: maxEdgeDistance, height: 0)
|
|
|
|
self.accumulatedOffset = self.currentOffset
|
|
|
|
}
|
2023-02-20 10:57:16 +01:00
|
|
|
|
|
|
|
HapticService.shared.fireHaptic(of: .animation)
|
|
|
|
} else if self.currentOffset.width < -maxEdgeDistance {
|
2023-02-20 15:21:39 +01:00
|
|
|
withAnimation(.linear(duration: 0.15)) {
|
|
|
|
self.currentOffset = CGSize(width: -maxEdgeDistance, height: 0)
|
|
|
|
self.accumulatedOffset = self.currentOffset
|
|
|
|
}
|
2023-02-20 10:57:16 +01:00
|
|
|
|
|
|
|
HapticService.shared.fireHaptic(of: .animation)
|
2023-01-14 20:45:49 +01:00
|
|
|
}
|
|
|
|
}
|
2023-01-13 21:11:30 +01:00
|
|
|
}
|