Impressia/Vernissage/Widgets/ImageViewer.swift

260 lines
10 KiB
Swift
Raw Normal View History

2023-01-13 21:11:30 +01:00
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
2023-03-28 10:35:38 +02:00
// Licensed under the Apache License 2.0.
2023-01-13 21:11:30 +01:00
//
import SwiftUI
2023-04-07 14:20:12 +02:00
import ClientKit
2023-01-13 21:11:30 +01:00
2023-03-09 08:00:40 +01:00
struct ImageViewer: View {
2023-01-13 21:11:30 +01:00
@Environment(\.dismiss) private var dismiss
private let attachmentModel: AttachmentModel
2023-02-20 15:21:39 +01:00
private let image: Image
private let closeDragDistance = UIScreen.main.bounds.height / 1.8
2023-03-08 16:57:39 +01:00
private let imageHeight: Double
private let imageWidth: Double
private let imagePosition: Double?
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
2023-02-20 10:57:16 +01:00
// 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
init(attachmentModel: AttachmentModel, imagePosition: Double) {
self.attachmentModel = attachmentModel
self.imagePosition = imagePosition
if let data = attachmentModel.data, let uiImage = UIImage(data: data) {
2023-02-20 14:25:46 +01:00
self.image = Image(uiImage: uiImage)
2023-03-08 16:57:39 +01:00
self.imageHeight = uiImage.size.height
self.imageWidth = uiImage.size.width
2023-02-20 14:25:46 +01:00
} else {
2023-02-20 15:21:39 +01:00
self.image = Image(systemName: "photo")
2023-03-08 16:57:39 +01:00
self.imageHeight = 200
self.imageWidth = 200
2023-02-20 14:25:46 +01:00
}
}
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(attachmentModel.id)
2023-02-20 15:21:39 +01:00
.offset(currentOffset)
.rotationEffect(rotationAngle)
.scaleEffect(finalMagnification + currentMagnification)
.gesture(dragGesture)
.gesture(magnificationGesture)
.gesture(doubleTapGesture)
.gesture(tapGesture)
2023-03-08 16:57:39 +01:00
.onAppear {
self.currentOffset = self.calculateStartingOffset()
withAnimation {
self.currentOffset = CGSize.zero
}
}
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 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 { _ in
2023-02-20 15:21:39 +01:00
self.revertToPrecalculatedMagnification()
2023-01-13 21:11:30 +01:00
}
}
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)
2023-02-20 10:57:16 +01:00
// 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
2023-02-21 07:19:02 +01:00
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-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
}
}
2023-01-13 21:11:30 +01:00
var tapGesture: some Gesture {
TapGesture().onEnded({ _ in
2023-03-08 16:57:39 +01:00
withAnimation {
self.currentOffset = self.calculateStartingOffset()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
2023-03-08 16:57:39 +01:00
withoutAnimation {
self.dismiss()
}
2023-02-20 15:21:39 +01:00
}
2023-01-13 21:11:30 +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)
2023-02-20 10:57:16 +01:00
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-03-08 16:57:39 +01:00
private func calculateStartingOffset() -> CGSize {
2023-03-09 08:00:40 +01:00
// Image size on the screen.
2023-03-08 16:57:39 +01:00
let imageOnScreenHeight = self.calculateHeight(width: self.imageWidth, height: self.imageHeight)
2023-03-09 08:00:40 +01:00
// Calculate full space for image.
let safeAreaInsetsTop = UIApplication.shared.keyWindow?.safeAreaInsets.top ?? 20.0
let safeAreaInsetsBottom = UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 20.0
let spaceForNavigationBar = self.spaceForNavigationBar()
let spaceForImage = UIScreen.main.bounds.height - safeAreaInsetsTop - safeAreaInsetsBottom - spaceForNavigationBar
2023-03-09 08:00:40 +01:00
// Calculate empty space.
let emptySpace = spaceForImage - imageOnScreenHeight
// Shift of image when image is exactly at the top of the screen.
let imageShiftToTopScreen = -(emptySpace / 2)
// Shidt of image also when image has been scrolled top/bottom.
let imageShiftToImagePosition = imageShiftToTopScreen - (self.imagePosition ?? 0.0)
2023-03-09 08:00:40 +01:00
// Calculate image shift.
return CGSize(width: 0, height: imageShiftToImagePosition)
2023-03-08 16:57:39 +01:00
}
2023-03-09 08:00:40 +01:00
private func spaceForNavigationBar() -> CGFloat {
if UIScreen.main.bounds.height == 852.0 || UIScreen.main.bounds.height == 932.0 {
// iPhone 14 Pro, iPhone 14 Pro Max
return 78.0
2023-03-08 16:57:39 +01:00
} else {
2023-03-09 08:00:40 +01:00
// iPhone SE, iPhone 12 Pro, iPhone 12 Pro Max, iPhone 13 Pro, iPhone 13 Pro Max
return 88.0
2023-03-08 16:57:39 +01:00
}
}
2023-03-08 16:57:39 +01:00
private func calculateHeight(width: Double, height: Double) -> CGFloat {
let divider = width / UIScreen.main.bounds.size.width
return height / divider
}
2023-01-13 21:11:30 +01:00
}