summaryrefslogtreecommitdiff
path: root/Sora/Views/InteractiveImageView.swift
blob: 052e9cddcb95286079c369e80230fcbd1c66d46e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import SwiftUI

struct InteractiveImageView<MenuItems: View>: View {
  let image: Image
  let contextMenu: MenuItems
  @State private var screenWidth = 0.0
  @State private var screenHeight = 0.0
  @State private var currentScale = 1.0
  @State private var previousScale = 1.0
  @State private var currentOffset: CGSize = .zero
  @State private var previousOffset: CGSize = .zero
  @State private var zoomAnchor: UnitPoint = .center

  var body: some View {
    Group {
      image
        .resizable()
        .scaledToFit()
        .contextMenu { if currentScale == 1 { contextMenu } }
    }
    .scaleEffect(currentScale, anchor: zoomAnchor)
    .offset(currentOffset)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .clipped()
    .background(
      GeometryReader { geometry in
        Color.clear
          .onAppear {
            screenWidth = geometry.size.width
            screenHeight = geometry.size.height
          }
      }
    )
    .gesture(
      MagnifyGesture()
        .onChanged { gesture in
          withAnimation(.interactiveSpring()) {
            if previousScale == 1 {
              zoomAnchor = gesture.startAnchor
            }

            currentScale = max(previousScale * gesture.magnification, 1)
          }
        }
        .onEnded { _ in
          previousScale = currentScale
        }
        .simultaneously(
          with: (currentScale > 1 ? DragGesture(minimumDistance: 0) : nil)
            .onChanged { gesture in
              withAnimation(.interactiveSpring()) {
                var newOffset: CGSize = .zero
                let offset = gesture.translation

                newOffset.width = offset.width + previousOffset.width
                newOffset.height = offset.height + previousOffset.height

                currentOffset = clampOffset(offset: newOffset)
              }
            }
            .onEnded { _ in
              previousOffset = currentOffset
            }
        )
    )
    .highPriorityGesture(
      TapGesture(count: 2)
        .onEnded {
          withAnimation {
            currentScale = currentScale == 1 ? 2 : 1
            previousScale = currentScale
            currentOffset = .zero
            previousOffset = .zero
            zoomAnchor = .center
          }
        }
    )
  }

  private func clampOffset(offset: CGSize = .zero) -> CGSize {
    var newOffset = offset

    if currentScale > 1 {
      let maxX = ((screenWidth * currentScale) - screenWidth) / 2
      let maxY = ((screenHeight * currentScale) - screenHeight) / 2

      newOffset.width = min(max(-maxX, newOffset.width), maxX)
      newOffset.height = min(max(-maxY, newOffset.height), maxY)
    } else {
      newOffset = .zero
    }

    return newOffset
  }
}

#Preview {
  InteractiveImageView(
    image: Image(systemName: "photo"),
    contextMenu: EmptyView()
  )
}