Skip to content

Instantly share code, notes, and snippets.

@1998code
Last active January 29, 2026 13:03
Show Gist options
  • Select an option

  • Save 1998code/92349bc8118a78202ed1837572d5b188 to your computer and use it in GitHub Desktop.

Select an option

Save 1998code/92349bc8118a78202ed1837572d5b188 to your computer and use it in GitHub Desktop.
Liquid Glass Magnifier Loupe
//
// ContentView.swift
// Liquid Magnifying Glass
//
// A draggable magnifier loupe with liquid glass effect, zoom controls, and
// full-screen gesture handling for reliable repeated drags.
//
// Created by Ming on 29/1/2026.
//
import SwiftUI
struct ContentView: View {
// MARK: - Loupe & zoom constants
private let loupeSize: CGFloat = 125
private let zoomMin: CGFloat = 1.5
private let zoomMax: CGFloat = 4.0
private let zoomStep: CGFloat = 0.3
private let zoomButtonSize: CGFloat = 44
private let zoomButtonOffset: CGFloat = 90
// MARK: - State
/// Current offset of the loupe from screen center (updated during drag).
@State private var glassOffset: CGSize = .zero
/// Loupe position when the current drag started (used to compute translation).
@State private var dragStartOffset: CGSize = .zero
/// True while the user is dragging the loupe (e.g. for blur or future effects).
@State private var isDragging = false
/// Magnification level inside the loupe (1.5–4.0).
@State private var magnification: CGFloat = 2.2
// MARK: - Body
var body: some View {
GeometryReader { geometry in
ZStack {
backgroundImage
magnifiedLoupe(in: geometry.size)
}
.ignoresSafeArea()
}
}
// MARK: - Background
private var backgroundImage: some View {
Image("Background")
.resizable()
.scaledToFill()
}
// MARK: - Magnified loupe overlay
/// Full-screen loupe overlay: magnified content in a circle with liquid glass,
/// zoom buttons, and a drag gesture on the overlay (so the gesture target never moves).
private func magnifiedLoupe(in size: CGSize) -> some View {
let centerX = size.width / 2 + glassOffset.width
let centerY = size.height / 2 + glassOffset.height
let scaleAnchor = UnitPoint(x: centerX / size.width, y: centerY / size.height)
return ZStack {
// Liquid glass circle (slightly larger so it frames the magnified content).
Circle()
.glassEffect(.clear.interactive())
.frame(width: loupeSize + 1, height: loupeSize + 1)
.position(x: centerX, y: centerY)
// Magnified image: scale around the point under the glass, then mask to a circle.
Image("Background")
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.scaleEffect(magnification, anchor: scaleAnchor)
.mask(circleMask(in: size, centerX: centerX, centerY: centerY))
// Invisible circle for layout; hit testing disabled so the overlay gesture receives touches.
Circle()
.fill(.clear)
.frame(width: loupeSize, height: loupeSize)
.position(x: centerX, y: centerY)
.contentShape(Circle())
.allowsHitTesting(false)
zoomOutButton(centerX: centerX, centerY: centerY)
zoomInButton(centerX: centerX, centerY: centerY)
}
.frame(width: size.width, height: size.height)
.contentShape(Rectangle())
.gesture(loupeDragGesture(size: size))
}
/// Circular mask for the magnified content, centered at the loupe.
private func circleMask(in size: CGSize, centerX: CGFloat, centerY: CGFloat) -> some View {
ZStack {
Circle()
.fill(.white)
.frame(width: loupeSize, height: loupeSize)
.position(x: centerX, y: centerY)
}
.frame(width: size.width, height: size.height)
}
/// Drag gesture on the full overlay
/// Only updates loupe position when the drag starts inside the loupe circle.
private func loupeDragGesture(size: CGSize) -> some Gesture {
DragGesture()
.onChanged { value in
let centerX = size.width / 2 + glassOffset.width
let centerY = size.height / 2 + glassOffset.height
if !isDragging {
// Ignore drags that start outside the loupe (e.g. on zoom buttons).
let dx = value.startLocation.x - centerX
let dy = value.startLocation.y - centerY
if dx * dx + dy * dy > (loupeSize / 2) * (loupeSize / 2) {
return
}
dragStartOffset = glassOffset
}
isDragging = true
glassOffset = CGSize(
width: dragStartOffset.width + value.translation.width,
height: dragStartOffset.height + value.translation.height
)
}
.onEnded { _ in
isDragging = false
dragStartOffset = glassOffset
}
}
// MARK: - Zoom buttons
private func zoomOutButton(centerX: CGFloat, centerY: CGFloat) -> some View {
Button {
withAnimation(.easeInOut(duration: 0.25)) {
magnification = max(zoomMin, magnification - zoomStep)
}
} label: {
Image(systemName: "minus")
.font(.system(size: 12, weight: .medium))
.frame(width: zoomButtonSize / 2, height: zoomButtonSize / 2)
}
.tint(.white)
.glassEffect(.clear.interactive())
.position(x: centerX - zoomButtonOffset, y: centerY)
}
private func zoomInButton(centerX: CGFloat, centerY: CGFloat) -> some View {
Button {
withAnimation(.easeInOut(duration: 0.25)) {
magnification = min(zoomMax, magnification + zoomStep)
}
} label: {
Image(systemName: "plus")
.font(.system(size: 12, weight: .medium))
.frame(width: zoomButtonSize / 2, height: zoomButtonSize / 2)
}
.tint(.white)
.glassEffect(.clear.interactive())
.position(x: centerX + zoomButtonOffset, y: centerY)
}
}
// MARK: - Preview
#Preview {
ContentView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment