Skip to content

Commit 112098d

Browse files
#1: Basic panning and zooming
Maps can now be panned and zoomed around. The world can't go beyond what is currently generated, but that will be taken care of in a future commit. Keyboard shortcuts also need to be implemented as well for consistency and accessibility across input mechanisms besides touch and the mouse.
1 parent 38e9954 commit 112098d

File tree

5 files changed

+244
-5
lines changed

5 files changed

+244
-5
lines changed

MCMaps/Vendor/Zoomable.swift

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
//
2+
// Zoomable.swift
3+
// MCMaps
4+
//
5+
// Created by Marquis Kurt on 23-02-2025.
6+
// Original: https://github.com/ryohey/Zoomable
7+
//
8+
9+
import SwiftUI
10+
11+
/// A modifier used for handling zooming and panning interactions.
12+
///
13+
/// When a user interacts with a view containing this modifier, they can pinch to zoom and pan around with their
14+
/// finger. On macOS, the pointer style is automatically updated depending on the zoom's state.
15+
///
16+
/// - Note: Currently, scrolling with two fingers isn't supported.
17+
struct ZoomableModifier: ViewModifier {
18+
/// The minimum zoom scale factor.
19+
let minZoomScale: CGFloat
20+
21+
/// The factor to scale by when the double-tap gesture is invoked.
22+
let doubleTapZoomScale: CGFloat
23+
24+
@State private var lastTransform: CGAffineTransform = .identity
25+
@State private var transform: CGAffineTransform = .identity
26+
@State private var contentSize: CGSize = .zero
27+
#if os(macOS)
28+
@State private var pointerStyle: PointerStyle = .zoomIn
29+
#endif
30+
31+
func body(content: Content) -> some View {
32+
content
33+
.background(alignment: .topLeading) {
34+
GeometryReader { proxy in
35+
Color.clear
36+
.onAppear {
37+
contentSize = proxy.size
38+
}
39+
}
40+
}
41+
.animatableTransformEffect(transform)
42+
.gesture(dragGesture, including: transform == .identity ? .none : .all)
43+
.gesture(magnificationGesture)
44+
.gesture(doubleTapGesture)
45+
#if os(macOS)
46+
.pointerStyle(pointerStyle)
47+
#endif
48+
}
49+
50+
private var magnificationGesture: some Gesture {
51+
MagnifyGesture(minimumScaleDelta: 0)
52+
.onChanged { value in
53+
let newTransform = CGAffineTransform.anchoredScale(
54+
scale: value.magnification,
55+
anchor: value.startAnchor.scaledBy(contentSize)
56+
)
57+
58+
withAnimation(.interactiveSpring) {
59+
transform = lastTransform.concatenating(newTransform)
60+
}
61+
}
62+
.onEnded { _ in
63+
onEndGesture()
64+
}
65+
}
66+
67+
private var doubleTapGesture: some Gesture {
68+
SpatialTapGesture(count: 2)
69+
.onEnded { value in
70+
let newTransform: CGAffineTransform =
71+
if transform.isIdentity {
72+
.anchoredScale(scale: doubleTapZoomScale, anchor: value.location)
73+
} else {
74+
.identity
75+
}
76+
77+
withAnimation(.linear(duration: 0.15)) {
78+
transform = newTransform
79+
lastTransform = newTransform
80+
#if os(macOS)
81+
pointerStyle = newTransform.isIdentity ? .zoomIn : .zoomOut
82+
#endif
83+
}
84+
}
85+
}
86+
87+
private var dragGesture: some Gesture {
88+
DragGesture()
89+
.onChanged { value in
90+
#if os(macOS)
91+
pointerStyle = .grabActive
92+
#endif
93+
withAnimation(.interactiveSpring) {
94+
transform = lastTransform.translatedBy(
95+
x: value.translation.width / transform.scaleX,
96+
y: value.translation.height / transform.scaleY
97+
)
98+
}
99+
}
100+
.onEnded { _ in
101+
onEndGesture()
102+
}
103+
}
104+
105+
private func onEndGesture() {
106+
let newTransform = limitTransform(transform)
107+
108+
#if os(macOS)
109+
pointerStyle = .grabIdle
110+
#endif
111+
112+
withAnimation(.snappy(duration: 0.1)) {
113+
transform = newTransform
114+
lastTransform = newTransform
115+
}
116+
}
117+
118+
private func limitTransform(_ transform: CGAffineTransform) -> CGAffineTransform {
119+
let scaleX = transform.scaleX
120+
let scaleY = transform.scaleY
121+
122+
if scaleX < minZoomScale || scaleY < minZoomScale {
123+
return .identity
124+
}
125+
126+
let maxX = contentSize.width * (scaleX - 1)
127+
let maxY = contentSize.height * (scaleY - 1)
128+
129+
if transform.tx > 0
130+
|| transform.tx < -maxX
131+
|| transform.ty > 0
132+
|| transform.ty < -maxY
133+
{ // swiftlint:disable:this opening_brace
134+
// swiftlint:disable identifier_name
135+
let tx = min(max(transform.tx, -maxX), 0)
136+
let ty = min(max(transform.ty, -maxY), 0)
137+
// swiftlint:enable identifier_name
138+
var transform = transform
139+
transform.tx = tx
140+
transform.ty = ty
141+
return transform
142+
}
143+
144+
return transform
145+
}
146+
}
147+
148+
extension View {
149+
/// Allow zooming and panning this view in its parent container.
150+
///
151+
/// When a user interacts with a view containing this modifier, they can pinch to zoom and pan around with their
152+
/// finger. On macOS, the pointer style is automatically updated depending on the zoom's state.
153+
///
154+
/// - Note: Currently, scrolling with two fingers isn't supported.
155+
///
156+
/// - Parameter minZoomScale: The minimum scale factor to allow zooming to.
157+
/// - Parameter doubleTapZoomScale: The scale to zoom to/from when the double tap gesture is invoked.
158+
@ViewBuilder
159+
public func zoomable(
160+
minZoomScale: CGFloat = 1,
161+
doubleTapZoomScale: CGFloat = 3
162+
) -> some View {
163+
modifier(
164+
ZoomableModifier(
165+
minZoomScale: minZoomScale,
166+
doubleTapZoomScale: doubleTapZoomScale
167+
))
168+
}
169+
170+
/// Allow zooming and panning this view in its parent container.
171+
///
172+
/// When a user interacts with a view containing this modifier, they can pinch to zoom and pan around with their
173+
/// finger. On macOS, the pointer style is automatically updated depending on the zoom's state.
174+
///
175+
/// - Note: Currently, scrolling with two fingers isn't supported.
176+
///
177+
/// - Parameter minZoomScale: The minimum scale factor to allow zooming to.
178+
/// - Parameter doubleTapZoomScale: The scale to zoom to/from when the double tap gesture is invoked.
179+
/// - Parameter outOfBoundsColor: The background color to apply when the view appears out of bounds during zooming.
180+
@ViewBuilder
181+
public func zoomable(
182+
minZoomScale: CGFloat = 1,
183+
doubleTapZoomScale: CGFloat = 3,
184+
outOfBoundsColor: Color = .clear
185+
) -> some View {
186+
GeometryReader { _ in
187+
ZStack {
188+
outOfBoundsColor
189+
self.zoomable(
190+
minZoomScale: minZoomScale,
191+
doubleTapZoomScale: doubleTapZoomScale
192+
)
193+
}
194+
}
195+
}
196+
}
197+
198+
extension View {
199+
@ViewBuilder
200+
fileprivate func animatableTransformEffect(_ transform: CGAffineTransform) -> some View {
201+
scaleEffect(
202+
x: transform.scaleX,
203+
y: transform.scaleY,
204+
anchor: .zero
205+
)
206+
.offset(x: transform.tx, y: transform.ty)
207+
}
208+
}
209+
210+
extension UnitPoint {
211+
fileprivate func scaledBy(_ size: CGSize) -> CGPoint {
212+
.init(
213+
x: x * size.width,
214+
y: y * size.height
215+
)
216+
}
217+
}
218+
219+
extension CGAffineTransform {
220+
fileprivate static func anchoredScale(scale: CGFloat, anchor: CGPoint) -> CGAffineTransform {
221+
CGAffineTransform(translationX: anchor.x, y: anchor.y)
222+
.scaledBy(x: scale, y: scale)
223+
.translatedBy(x: -anchor.x, y: -anchor.y)
224+
}
225+
226+
fileprivate var scaleX: CGFloat {
227+
sqrt(a * a + c * c)
228+
}
229+
230+
fileprivate var scaleY: CGFloat {
231+
sqrt(b * b + d * d)
232+
}
233+
}

MCMaps/Views/AdaptableSidebarSheetView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ private struct AdaptableSidebarSheetInternalView<Content: View, Sheet: View>: Vi
183183
Spacer()
184184
}
185185
.frame(width: proxy.size.width * preferredSidebarWidthFraction)
186+
.shadow(radius: 2)
186187
.padding()
187188
Spacer()
188189
}

MCMaps/Views/CartographyMapSplitView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ struct CartographyMapSplitView: View {
5959
.inspectorColumnWidth(min: 300, ideal: 325)
6060
}
6161
}
62+
#if os(macOS)
6263
.navigationSubtitle(subtitle)
64+
#endif
6365
}
6466
}

MCMaps/Views/Maps/CartographyMapView.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ enum CartographyMapViewState: Equatable {
2525
/// A view that displays a Minecraft world map.
2626
///
2727
/// This view is designed to handle dynamic loading of the image, displaying an unavailable message when the map could
28-
/// not be loaded.
28+
/// not be loaded. When the map is available and displayed, players can zoom and pan around the map to inspect it more
29+
/// closely through pinching to zoom and dragging to pan. On macOS, the pointer will change to display zooming in and
30+
/// out, along with dragging.
2931
struct CartographyMapView: View {
3032
/// The view's loading state.
3133
var state: CartographyMapViewState
@@ -36,10 +38,10 @@ struct CartographyMapView: View {
3638
case .loading:
3739
ProgressView()
3840
case .success(let data):
39-
VStack {
40-
Image(data: data)?.resizable()
41-
.scaledToFill()
42-
}
41+
Image(data: data)?.resizable()
42+
.interpolation(.none)
43+
.scaledToFill()
44+
.zoomable()
4345
case .unavailable:
4446
ContentUnavailableView("No Map Available", systemImage: "map")
4547
}

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,4 @@ LICENSE.txt or visit https://www.mozilla.org/en-US/MPL/2.0/.
5555
**Alidade** is made possible by the following open source libraries:
5656

5757
- [**Cubiomes**](https://github.com/Cubitect/cubiomes) - MIT License
58+
- [**Zoomable**](https://github.com/ryohey/Zoomable) - MIT License

0 commit comments

Comments
 (0)