Last active
January 29, 2026 13:03
-
-
Save 1998code/92349bc8118a78202ed1837572d5b188 to your computer and use it in GitHub Desktop.
Liquid Glass Magnifier Loupe
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // 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