Skip to content

Double Tap Zoom Image

An image that zooms in on the point you double tap and returns to fit on the next double tap.

Double Tap Zoom Image, playing
Download .swift
Origin
Original work
Found by
double tap · zoom · image · photo · pan · spatial tap · doble toque · zoom · imagen · foto · ampliar · desplazar

An image that zooms in on the point you double tap and returns to fit on the next double tap.

While zoomed, a drag pans the image. At rest it leaves drags to the scroll view around it. With Reduce Motion on, the zoom changes without animating.

struct DoubleTapZoomImageDemo: View {
private let photo = demoPhoto()
@State private var zoomTarget: UnitPoint?
/// Only for this demo: it plays by itself until someone touches it.
@State private var isAutoPlaying = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
VStack(spacing: 16) {
DoubleTapZoomImage(image: photo, label: "City skyline at night", programmaticZoom: zoomTarget)
.aspectRatio(3 / 4, contentMode: .fit)
.clipShape(.rect(cornerRadius: 24))
Label("Double tap anywhere", systemImage: "hand.tap.fill")
.font(.headline)
.foregroundStyle(.secondary)
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false })
.task(id: reduceMotion) {
guard !reduceMotion else { return }
await playZooms()
}
}
// Only the demo plays by itself, because a real double tap needs a finger.
private func playZooms() async {
let targets: [UnitPoint?] = [UnitPoint(x: 0.75, y: 0.2), nil, UnitPoint(x: 0.3, y: 0.7), nil]
while isAutoPlaying {
for target in targets {
try? await Task.sleep(for: .seconds(1.2))
guard !Task.isCancelled, isAutoPlaying else { return }
zoomTarget = target
}
}
}
}
private func demoPhoto() -> Image {
let size = CGSize(width: 900, height: 1200)
return Image(size: size, label: Text("City skyline at night")) { context in
context.fill(
Path(CGRect(origin: .zero, size: size)),
with: .linearGradient(Gradient(colors: [.black, .indigo, .purple]), startPoint: .zero, endPoint: CGPoint(x: 0, y: 1200))
)
context.fill(Path(ellipseIn: CGRect(x: 620, y: 140, width: 150, height: 150)), with: .color(.white.opacity(0.9)))
let heights: [CGFloat] = [520, 760, 440, 880, 620, 700, 480]
for (column, height) in heights.enumerated() {
let x = CGFloat(column) * 130 - 5
let tower = CGRect(x: x, y: 1200 - height, width: 120, height: height)
context.fill(Path(tower), with: .color(Color(white: 0.09)))
for row in stride(from: tower.minY + 30, to: tower.maxY - 30, by: 64) {
for offset in [CGFloat(18), 70] where (column + Int(row) / 64 + Int(offset)).isMultiple(of: 3) == false {
context.fill(Path(CGRect(x: x + offset, y: row, width: 32, height: 34)), with: .color(.yellow.opacity(0.8)))
}
}
}
}
}
ParameterTypeDefaultDescription
imageImageRequired
labelLocalizedStringResourceRequired
zoomCGFloat2.5
programmaticZoomUnitPoint?nilZooms in on this point without a touch, for demos. nil returns to fit.

Copy this code into your project, or download the file.

DoubleTapZoomImage.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
enum DoubleTapZoomImageMath {
/// How far a zoomed image can move before showing an empty edge.
static func clampedOffset(_ offset: CGSize, zoom: CGFloat, in size: CGSize) -> CGSize {
let limit = CGSize(width: max((zoom - 1) * size.width / 2, 0), height: max((zoom - 1) * size.height / 2, 0))
return CGSize(
width: min(max(offset.width, -limit.width), limit.width),
height: min(max(offset.height, -limit.height), limit.height)
)
}
/// The offset that brings a tapped point as close to the center as the image's edges allow.
static func offset(centering point: CGPoint, zoom: CGFloat, in size: CGSize) -> CGSize {
let wanted = CGSize(width: (size.width / 2 - point.x) * zoom, height: (size.height / 2 - point.y) * zoom)
return clampedOffset(wanted, zoom: zoom, in: size)
}
}
/// An image that zooms in on the point you double tap and returns to fit on the next double tap.
///
/// While zoomed, a drag pans the image. At rest it leaves drags to the scroll view around it.
/// With Reduce Motion on, the zoom changes without animating.
struct DoubleTapZoomImage: View {
let image: Image
let label: LocalizedStringResource
var zoom: CGFloat = 2.5
/// Zooms in on this point without a touch, for demos. `nil` returns to fit.
var programmaticZoom: UnitPoint? = nil
@State private var isZoomed = false
@State private var offset = CGSize.zero
@GestureState private var translation = CGSize.zero
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var targetZoom: CGFloat { max(zoom, 1) }
var body: some View {
GeometryReader { geometry in
let size = geometry.size
let shown = CGSize(width: offset.width + translation.width, height: offset.height + translation.height)
image
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.scaleEffect(isZoomed ? targetZoom : 1)
.offset(isZoomed ? DoubleTapZoomImageMath.clampedOffset(shown, zoom: targetZoom, in: size) : .zero)
.frame(width: size.width, height: size.height)
.contentShape(.rect)
.gesture(
SpatialTapGesture(count: 2).onEnded { value in
toggleZoom(at: value.location, in: size)
}
)
.gesture(pan(in: size), including: isZoomed ? .all : .subviews)
.onChange(of: programmaticZoom) { _, target in
setZoom(target.map { CGPoint(x: $0.x * size.width, y: $0.y * size.height) }, in: size)
}
.accessibilityLabel(label)
.accessibilityValue(isZoomed ? "Zoomed in" : "Fit")
.accessibilityAddTraits(.isImage)
.accessibilityAction(named: isZoomed ? "Zoom out" : "Zoom in") {
toggleZoom(at: CGPoint(x: size.width / 2, y: size.height / 2), in: size)
}
}
// A photo is not mirrored in right-to-left layouts, so neither is the geometry that moves it.
.environment(\.layoutDirection, .leftToRight)
.clipped()
}
private func pan(in size: CGSize) -> some Gesture {
DragGesture()
.updating($translation) { value, state, _ in
state = value.translation
}
.onEnded { value in
let moved = CGSize(width: offset.width + value.translation.width, height: offset.height + value.translation.height)
offset = DoubleTapZoomImageMath.clampedOffset(moved, zoom: targetZoom, in: size)
}
}
private func toggleZoom(at point: CGPoint, in size: CGSize) {
setZoom(isZoomed ? nil : point, in: size)
}
private func setZoom(_ point: CGPoint?, in size: CGSize) {
withAnimation(reduceMotion ? nil : .spring(duration: 0.4, bounce: 0.15)) {
offset = point.map { DoubleTapZoomImageMath.offset(centering: $0, zoom: targetZoom, in: size) } ?? .zero
isZoomed = point != nil
}
}
}
// MARK: - Demo
struct DoubleTapZoomImageDemo: View {
private let photo = demoPhoto()
@State private var zoomTarget: UnitPoint?
/// Only for this demo: it plays by itself until someone touches it.
@State private var isAutoPlaying = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
VStack(spacing: 16) {
DoubleTapZoomImage(image: photo, label: "City skyline at night", programmaticZoom: zoomTarget)
.aspectRatio(3 / 4, contentMode: .fit)
.clipShape(.rect(cornerRadius: 24))
Label("Double tap anywhere", systemImage: "hand.tap.fill")
.font(.headline)
.foregroundStyle(.secondary)
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false })
.task(id: reduceMotion) {
guard !reduceMotion else { return }
await playZooms()
}
}
// Only the demo plays by itself, because a real double tap needs a finger.
private func playZooms() async {
let targets: [UnitPoint?] = [UnitPoint(x: 0.75, y: 0.2), nil, UnitPoint(x: 0.3, y: 0.7), nil]
while isAutoPlaying {
for target in targets {
try? await Task.sleep(for: .seconds(1.2))
guard !Task.isCancelled, isAutoPlaying else { return }
zoomTarget = target
}
}
}
}
private func demoPhoto() -> Image {
let size = CGSize(width: 900, height: 1200)
return Image(size: size, label: Text("City skyline at night")) { context in
context.fill(
Path(CGRect(origin: .zero, size: size)),
with: .linearGradient(Gradient(colors: [.black, .indigo, .purple]), startPoint: .zero, endPoint: CGPoint(x: 0, y: 1200))
)
context.fill(Path(ellipseIn: CGRect(x: 620, y: 140, width: 150, height: 150)), with: .color(.white.opacity(0.9)))
let heights: [CGFloat] = [520, 760, 440, 880, 620, 700, 480]
for (column, height) in heights.enumerated() {
let x = CGFloat(column) * 130 - 5
let tower = CGRect(x: x, y: 1200 - height, width: 120, height: height)
context.fill(Path(tower), with: .color(Color(white: 0.09)))
for row in stride(from: tower.minY + 30, to: tower.maxY - 30, by: 64) {
for offset in [CGFloat(18), 70] where (column + Int(row) / 64 + Int(offset)).isMultiple(of: 3) == false {
context.fill(Path(CGRect(x: x + offset, y: row, width: 32, height: 34)), with: .color(.yellow.opacity(0.8)))
}
}
}
}
}
#Preview {
DoubleTapZoomImageDemo()
}