├── .gitignore ├── Package.resolved ├── Package.swift ├── README.md └── Sources └── PixelCanvas ├── Layout └── PixelCanvasLayout.swift ├── PixelCanvas.swift ├── PixelCanvasView.swift └── Zoom ├── PixelCanvasZoomShader.metal └── PixelCanvasZoomView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | 10 | *.xcworkspace -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "canvas", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/heestand-xyz/Canvas", 7 | "state" : { 8 | "revision" : "d39be35a1d77b1e9e7ba8724b5593f35e93643c2", 9 | "version" : "2.5.0" 10 | } 11 | }, 12 | { 13 | "identity" : "coregraphicsextensions", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/heestand-xyz/CoreGraphicsExtensions", 16 | "state" : { 17 | "revision" : "a95735b2522f6e2ba9ab8a59fbf742e1723212ed", 18 | "version" : "2.0.0" 19 | } 20 | }, 21 | { 22 | "identity" : "displaylink", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/heestand-xyz/DisplayLink", 25 | "state" : { 26 | "revision" : "e76ded3edb36b8b4ccfbe0c4a83c113c3fa9bef5", 27 | "version" : "2.0.1" 28 | } 29 | }, 30 | { 31 | "identity" : "logger", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/heestand-xyz/Logger", 34 | "state" : { 35 | "revision" : "8553f58e72b83598acc28d4ea93b1dc992c71067", 36 | "version" : "0.3.1" 37 | } 38 | }, 39 | { 40 | "identity" : "multiviews", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/heestand-xyz/MultiViews", 43 | "state" : { 44 | "revision" : "cfb3c945f89adebc13fa3c85f8cfa37f41946683", 45 | "version" : "3.0.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-collections", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/apple/swift-collections", 52 | "state" : { 53 | "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", 54 | "version" : "1.0.6" 55 | } 56 | } 57 | ], 58 | "version" : 2 59 | } 60 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "PixelCanvas", 7 | platforms: [ 8 | .iOS(.v17), 9 | .macOS(.v14), 10 | .visionOS(.v1) 11 | ], 12 | products: [ 13 | .library( 14 | name: "PixelCanvas", 15 | targets: ["PixelCanvas"]), 16 | ], 17 | dependencies: [ 18 | .package(url: "https://github.com/heestand-xyz/GestureCanvas", from: "1.5.0"), 19 | .package(url: "https://github.com/heestand-xyz/CoreGraphicsExtensions", from: "2.0.1"), 20 | .package(url: "https://github.com/heestand-xyz/TextureMap", from: "2.2.0"), 21 | ], 22 | targets: [ 23 | .target( 24 | name: "PixelCanvas", 25 | dependencies: [ 26 | "GestureCanvas", 27 | "CoreGraphicsExtensions", 28 | "TextureMap", 29 | ]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pixel Canvas 2 | 3 | Pan and zoom images down to pixel level. 4 | 5 | For iOS (v16), macOS (v13) and visionOS (v1). 6 | 7 | Note that pixel level detail with a SwiftUI shader is only available for iOS (v17), macOS (v14) and visionOS (v1). 8 | 9 | ```swift 10 | import SwiftUI 11 | import PixelCanvas 12 | 13 | struct ContentView: View { 14 | 15 | @StateObject private var pixelCanvas = PixelCanvas() 16 | 17 | var body: some View { 18 | PixelCanvasView( 19 | pixelCanvas, 20 | background: { pixels, frame in 21 | ZStack { 22 | pixels 23 | PixelCanvasLayout(frame: frame) { 24 | // Background 25 | } 26 | } 27 | }, 28 | foreground: { 29 | // Foreground 30 | } 31 | ) 32 | .onAppear { 33 | pixelCanvas.load( 34 | image: Image("..."), 35 | resolution: CGSize(width: 3_000, height: 2_000) 36 | ) 37 | } 38 | } 39 | } 40 | 41 | #Preview { 42 | ContentView() 43 | } 44 | ``` 45 | -------------------------------------------------------------------------------- /Sources/PixelCanvas/Layout/PixelCanvasLayout.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CoreGraphicsExtensions 3 | 4 | public struct PixelCanvasLayout: Layout { 5 | 6 | private let frame: CGRect 7 | 8 | public init(frame: CGRect) { 9 | self.frame = frame 10 | } 11 | 12 | public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { 13 | CGSize( 14 | width: proposal.width ?? 0.0, 15 | height: proposal.height ?? 0.0 16 | ) 17 | } 18 | 19 | public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { 20 | for subview in subviews { 21 | subview.place( 22 | at: bounds.origin + frame.origin, 23 | anchor: .topLeading, 24 | proposal: ProposedViewSize( 25 | width: frame.width, 26 | height: frame.height 27 | ) 28 | ) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/PixelCanvas/PixelCanvas.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Observation 3 | import SwiftUI 4 | import Combine 5 | import GestureCanvas 6 | import CoreGraphicsExtensions 7 | import TextureMap 8 | 9 | @MainActor 10 | public protocol PixelCanvasDelegate: AnyObject { 11 | 12 | func pixelCanvasDidTap(at location: CGPoint, with coordinate: GestureCanvasCoordinate) 13 | 14 | #if !os(macOS) 15 | func pixelCanvasAllowPinch(_ canvas: PixelCanvas) -> Bool 16 | #endif 17 | } 18 | 19 | @MainActor 20 | @Observable 21 | public final class PixelCanvas { 22 | 23 | public weak var delegate: PixelCanvasDelegate? 24 | 25 | public enum Placement: Int { 26 | case stretch 27 | case fit 28 | case fill 29 | case fixed 30 | } 31 | 32 | public struct Options { 33 | public var animationDuration: TimeInterval = 1.0 / 3.0 34 | public var checkerTransparency: Bool = true 35 | public var checkerOpacity: CGFloat = 0.5 36 | public var checkerSize: CGFloat = 64 37 | public var borderWidth: CGFloat = 1.0 38 | public var borderFadeRange: ClosedRange = 25...50 39 | public var borderOpacity: CGFloat = 0.25 40 | public var placement: Placement = .fit 41 | /// Won't use shader. 42 | public var alwaysUseImageCanvas: Bool = false 43 | /// Experimental. 44 | /// 45 | /// 3x3 interpolation when zoomed out. 46 | public var interpolate: Bool = false 47 | } 48 | public var options = Options() 49 | 50 | struct Content { 51 | let id: UUID 52 | let image: Image 53 | let resolution: CGSize 54 | } 55 | var content: Content? { 56 | didSet { 57 | zoomCoordinateOffsetUpdate.send(zoomCoordinateOffset()) 58 | } 59 | } 60 | 61 | internal let zoomCoordinateOffsetUpdate = PassthroughSubject() 62 | 63 | /// Moving (Panning or Zooming) 64 | public var isMoving: Bool { 65 | isPanning || isZooming 66 | } 67 | 68 | /// Panning 69 | public private(set) var isPanning: Bool = false { 70 | didSet { isPanningContinuation?.yield(isPanning) } 71 | } 72 | @ObservationIgnored 73 | public private(set) lazy var isPanningStream = AsyncStream { [weak self] continuation in 74 | self?.isPanningContinuation = continuation 75 | } 76 | @ObservationIgnored 77 | private var isPanningContinuation: AsyncStream.Continuation? 78 | 79 | /// Zooming 80 | public private(set) var isZooming: Bool = false { 81 | didSet { isZoomingContinuation?.yield(isZooming) } 82 | } 83 | @ObservationIgnored 84 | public private(set) lazy var isZoomingStream = AsyncStream { [weak self] continuation in 85 | self?.isZoomingContinuation = continuation 86 | } 87 | @ObservationIgnored 88 | private var isZoomingContinuation: AsyncStream.Continuation? 89 | 90 | /// Container Size 91 | public internal(set) var containerSize: CGSize = .one { 92 | didSet { 93 | containerSizeContinuation?.yield(containerSize) 94 | zoomCoordinateOffsetUpdate.send(zoomCoordinateOffset()) 95 | } 96 | } 97 | @ObservationIgnored 98 | public private(set) lazy var containerSizeStream = AsyncStream { [weak self] continuation in 99 | self?.containerSizeContinuation = continuation 100 | } 101 | @ObservationIgnored 102 | private var containerSizeContinuation: AsyncStream.Continuation? 103 | 104 | /// Coordinate 105 | public internal(set) var coordinate: GestureCanvasCoordinate = .zero { 106 | didSet { 107 | coordinateContinuation?.yield(coordinate) 108 | } 109 | } 110 | @ObservationIgnored 111 | public private(set) lazy var coordinateStream = AsyncStream { [weak self] continuation in 112 | self?.coordinateContinuation = continuation 113 | } 114 | @ObservationIgnored 115 | private var coordinateContinuation: AsyncStream.Continuation? 116 | 117 | /// Scale 118 | public var scale: CGFloat { 119 | get { 120 | coordinate.scale 121 | } 122 | set { 123 | coordinate.scale = newValue 124 | reFrame() 125 | } 126 | } 127 | /// Offset 128 | public var offset: CGPoint { 129 | get { 130 | coordinate.offset 131 | } 132 | set { 133 | coordinate.offset = newValue 134 | reFrame() 135 | } 136 | } 137 | 138 | /// Content Frame 139 | public internal(set) var contentFrame: CGRect = .one { 140 | didSet { 141 | contentFrameContinuation?.yield(contentFrame) 142 | } 143 | } 144 | @ObservationIgnored 145 | public private(set) lazy var contentFrameStream = AsyncStream { [weak self] continuation in 146 | self?.contentFrameContinuation = continuation 147 | } 148 | @ObservationIgnored 149 | private var contentFrameContinuation: AsyncStream.Continuation? 150 | 151 | /// Frame 152 | public var frame: CGRect { 153 | get { 154 | contentFrame 155 | } 156 | set { 157 | reFrame() 158 | } 159 | } 160 | 161 | struct Zoom { 162 | let coordinate: GestureCanvasCoordinate 163 | let animated: Bool 164 | } 165 | let canvasZoom = PassthroughSubject() 166 | 167 | public init() {} 168 | } 169 | 170 | // MARK: - Coordinates 171 | 172 | extension PixelCanvas { 173 | 174 | public func coordinate(contentResolution: CGSize, contentFrame: CGRect? = nil, edgeInsets: EdgeInsets? = nil) -> GestureCanvasCoordinate { 175 | Self.coordinate( 176 | contentResolution: contentResolution, 177 | contentFrame: contentFrame ?? CGRect(origin: .zero, size: contentResolution), 178 | containerSize: containerSize, 179 | edgeInsets: edgeInsets 180 | ) 181 | } 182 | 183 | private static func coordinate( 184 | contentResolution: CGSize, 185 | contentFrame: CGRect, 186 | containerSize: CGSize, 187 | edgeInsets: EdgeInsets? = nil 188 | ) -> GestureCanvasCoordinate { 189 | 190 | let contentSize: CGSize = contentResolution.place(in: containerSize, placement: .fit, roundToPixels: false) 191 | let containerAspectRatio: CGFloat = containerSize.aspectRatio 192 | 193 | let resolutionScale = contentSize.height / contentResolution.height 194 | var contentCropFrame = CGRect( 195 | origin: contentFrame.origin * resolutionScale, 196 | size: contentFrame.size * resolutionScale 197 | ) 198 | let contentCropAspectRatio: CGFloat = contentCropFrame.size.aspectRatio 199 | let cropScale: CGFloat = if contentCropAspectRatio > containerAspectRatio { 200 | contentCropFrame.width / contentSize.width 201 | } else { 202 | contentCropFrame.height / contentSize.height 203 | } 204 | let topLeadingPadding = CGPoint(x: edgeInsets?.leading ?? 0.0, 205 | y: edgeInsets?.top ?? 0.0) 206 | let bottomTrailingPadding = CGPoint(x: edgeInsets?.trailing ?? 0.0, 207 | y: edgeInsets?.bottom ?? 0.0) 208 | let padding: CGSize = (topLeadingPadding + bottomTrailingPadding).asSize 209 | contentCropFrame = CGRect(origin: contentCropFrame.origin - topLeadingPadding * cropScale, 210 | size: contentCropFrame.size + padding * cropScale) 211 | let contentPaddingCropAspectRatio: CGFloat = contentCropFrame.size.aspectRatio 212 | 213 | let containerCropFillSize = CGSize( 214 | width: containerAspectRatio < contentPaddingCropAspectRatio ? contentCropFrame.width : contentCropFrame.height * containerAspectRatio, 215 | height: containerAspectRatio > contentPaddingCropAspectRatio ? contentCropFrame.height : contentCropFrame.width / containerAspectRatio 216 | ) 217 | 218 | let contentOrigin: CGPoint = (containerSize - contentSize).asPoint / 2 219 | let centerOffset: CGPoint = (containerCropFillSize - contentCropFrame.size).asPoint / 2 220 | 221 | let scale: CGFloat = if containerAspectRatio > contentPaddingCropAspectRatio { 222 | containerSize.height / contentCropFrame.height 223 | } else { 224 | containerSize.width / contentCropFrame.width 225 | } 226 | let offset: CGPoint = -contentOrigin - contentCropFrame.origin * scale + centerOffset * scale 227 | 228 | return GestureCanvasCoordinate( 229 | offset: offset, 230 | scale: scale 231 | ) 232 | } 233 | 234 | func zoomCoordinateOffset() -> CGPoint { 235 | guard let content: Content else { return .zero } 236 | return -Self.contentOrigin(contentResolution: content.resolution, containerSize: containerSize) 237 | } 238 | } 239 | 240 | // MARK: - Frame 241 | 242 | extension PixelCanvas { 243 | 244 | func reFrame() { 245 | guard let content: Content else { return } 246 | contentFrame = Self.frame( 247 | contentResolution: content.resolution, 248 | containerSize: containerSize, 249 | coordinate: coordinate 250 | ) 251 | } 252 | 253 | static func frame( 254 | contentResolution: CGSize, 255 | containerSize: CGSize, 256 | coordinate: GestureCanvasCoordinate 257 | ) -> CGRect { 258 | let contentSize: CGSize = contentResolution.place(in: containerSize, placement: .fit, roundToPixels: false) 259 | let contentOrigin: CGPoint = contentOrigin(contentResolution: contentResolution, containerSize: containerSize) 260 | return CGRect(origin: contentOrigin + coordinate.offset, 261 | size: contentSize * coordinate.scale) 262 | } 263 | 264 | static func contentOrigin( 265 | contentResolution: CGSize, 266 | containerSize: CGSize 267 | ) -> CGPoint { 268 | let containerAspectRatio: CGFloat = containerSize.aspectRatio 269 | let contentSize: CGSize = contentResolution.place(in: containerSize, placement: .fit, roundToPixels: false) 270 | let contentAspectRatio: CGFloat = contentSize.aspectRatio 271 | if containerAspectRatio > contentAspectRatio { 272 | return CGPoint(x: (containerSize.width - contentSize.width) / 2, y: 0.0) 273 | } else if containerAspectRatio < contentAspectRatio { 274 | return CGPoint(x: 0.0, y: (containerSize.height - contentSize.height) / 2) 275 | } 276 | return .zero 277 | } 278 | } 279 | 280 | // MARK: - Transform 281 | 282 | extension PixelCanvas { 283 | 284 | struct Transform { 285 | let containerResolution: CGSize 286 | let contentResolution: CGSize 287 | let offset: CGPoint 288 | let scale: CGFloat 289 | } 290 | 291 | static func transform( 292 | contentResolution: CGSize, 293 | containerSize: CGSize, 294 | coordinate: GestureCanvasCoordinate 295 | ) -> Transform { 296 | let containerResolution: CGSize = containerSize * .pixelsPerPoint 297 | var offset = coordinate.offset 298 | offset /= coordinate.scale 299 | let relativeSize: CGSize = contentResolution.place(in: containerSize, placement: .fit, roundToPixels: false) 300 | var inspectOffset: CGPoint = ((offset + relativeSize / 2) * coordinate.scale - relativeSize / 2) 301 | inspectOffset /= relativeSize / containerSize 302 | inspectOffset /= coordinate.scale 303 | inspectOffset *= .pixelsPerPoint 304 | return Transform( 305 | containerResolution: containerResolution, 306 | contentResolution: contentResolution, 307 | offset: inspectOffset, 308 | scale: coordinate.scale 309 | ) 310 | } 311 | } 312 | 313 | // MARK: - Load 314 | 315 | extension PixelCanvas { 316 | 317 | #if os(macOS) 318 | @MainActor 319 | public func load(image: NSImage) { 320 | load(image: Image(nsImage: image), 321 | resolution: image.size * image.scale) 322 | } 323 | #else 324 | @MainActor 325 | public func load(image: UIImage) { 326 | load(image: Image(uiImage: image), 327 | resolution: image.size * image.scale) 328 | } 329 | #endif 330 | 331 | @MainActor 332 | public func load(image: Image, resolution: CGSize) { 333 | self.content = Content(id: UUID(), image: image, resolution: resolution) 334 | self.reFrame() 335 | 336 | } 337 | 338 | @MainActor 339 | public func unload() { 340 | self.content = nil 341 | } 342 | } 343 | 344 | // MARK: - Zoom 345 | 346 | extension PixelCanvas { 347 | 348 | @MainActor 349 | private func zoom( 350 | to coordinate: GestureCanvasCoordinate, 351 | animated: Bool = true 352 | ) { 353 | let zoom = Zoom( 354 | coordinate: coordinate, 355 | animated: animated 356 | ) 357 | canvasZoom.send(zoom) 358 | } 359 | 360 | @MainActor 361 | public func zoomToLocation( 362 | offset: CGPoint, 363 | scale: CGFloat, 364 | animated: Bool = true 365 | ) { 366 | let coordinate = GestureCanvasCoordinate( 367 | offset: offset, 368 | scale: scale 369 | ) 370 | zoom( 371 | to: coordinate, 372 | animated: animated 373 | ) 374 | } 375 | 376 | /// Zoom to Fill 377 | /// - Parameters: 378 | /// - padding: Padding in view points. 379 | @MainActor 380 | public func zoomToFill( 381 | edgeInsets: EdgeInsets? = nil, 382 | animated: Bool = true 383 | ) { 384 | guard let content: Content else { return } 385 | zoom( 386 | to: coordinate( 387 | contentResolution: content.resolution, 388 | edgeInsets: edgeInsets 389 | ), 390 | animated: animated 391 | ) 392 | } 393 | 394 | /// Zoom to Frame 395 | /// - Parameters: 396 | /// - contentFrame: A frame in pixels, top left is zero. 397 | /// - padding: Padding in view points. 398 | @MainActor 399 | public func zoomToFrame( 400 | contentFrame: CGRect, 401 | edgeInsets: EdgeInsets? = nil, 402 | animated: Bool = true 403 | ) { 404 | guard let content: Content else { return } 405 | zoom( 406 | to: coordinate( 407 | contentResolution: content.resolution, 408 | contentFrame: contentFrame, 409 | edgeInsets: edgeInsets 410 | ), 411 | animated: animated 412 | ) 413 | } 414 | } 415 | 416 | // MARK: Gesture Canvas Delegate 417 | 418 | extension PixelCanvas: GestureCanvasDelegate { 419 | 420 | public func gestureCanvasChanged(_ canvas: GestureCanvas, coordinate: GestureCanvasCoordinate) {} 421 | 422 | public func gestureCanvasBackgroundTap(_ canvas: GestureCanvas, at location: CGPoint) { 423 | delegate?.pixelCanvasDidTap(at: location, with: coordinate) 424 | } 425 | public func gestureCanvasBackgroundDoubleTap(_ canvas: GestureCanvas, at location: CGPoint) {} 426 | 427 | #if os(macOS) 428 | public func gestureCanvasDragSelectionStarted(_ canvas: GestureCanvas, at location: CGPoint) {} 429 | public func gestureCanvasDragSelectionUpdated(_ canvas: GestureCanvas, at location: CGPoint) {} 430 | public func gestureCanvasDragSelectionEnded(_ canvas: GestureCanvas, at location: CGPoint) {} 431 | 432 | public func gestureCanvasScrollStarted(_ canvas: GestureCanvas) {} 433 | public func gestureCanvasScrollEnded(_ canvas: GestureCanvas) {} 434 | 435 | @MainActor 436 | public func gestureCanvasContextMenu(_ canvas: GestureCanvas, at location: CGPoint) -> NSMenu? { nil } 437 | #else 438 | public func gestureCanvasContext(at location: CGPoint) -> CGPoint? { nil } 439 | public func gestureCanvasEditMenuInteractionDelegate() -> UIEditMenuInteractionDelegate? { nil } 440 | 441 | public func gestureCanvasAllowPinch(_ canvas: GestureCanvas) -> Bool { 442 | delegate?.pixelCanvasAllowPinch(self) ?? true 443 | } 444 | #endif 445 | 446 | public func gestureCanvasDidStartPan(_ canvas: GestureCanvas) { 447 | isPanning = true 448 | } 449 | public func gestureCanvasDidEndPan(_ canvas: GestureCanvas) { 450 | isPanning = false 451 | } 452 | public func gestureCanvasDidStartZoom(_ canvas: GestureCanvas) { 453 | isZooming = true 454 | } 455 | public func gestureCanvasDidEndZoom(_ canvas: GestureCanvas) { 456 | isZooming = false 457 | } 458 | } 459 | -------------------------------------------------------------------------------- /Sources/PixelCanvas/PixelCanvasView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import GestureCanvas 3 | import CoreGraphicsExtensions 4 | import DisplayLink 5 | 6 | public struct PixelCanvasView: View { 7 | 8 | @Bindable private var pixelCanvas: PixelCanvas 9 | @State private var gestureCanvas = GestureCanvas() 10 | 11 | private let background: (_ pixels: AnyView, _ frame: CGRect) -> Background 12 | private let foreground: () -> Foreground 13 | 14 | @State private var size: CGSize = .one 15 | 16 | public init( 17 | _ pixelCanvas: PixelCanvas, 18 | @ViewBuilder background: @escaping (AnyView, CGRect) -> Background = { pixels, _ in pixels }, 19 | @ViewBuilder foreground: @escaping () -> Foreground = { EmptyView() } 20 | ) { 21 | self.pixelCanvas = pixelCanvas 22 | self.background = background 23 | self.foreground = foreground 24 | } 25 | 26 | public var body: some View { 27 | ZStack { 28 | background(AnyView(pixelBody), pixelCanvas.contentFrame) 29 | GestureCanvasView(canvas: gestureCanvas) { $0 } content: { 30 | PixelCanvasLayout(frame: pixelCanvas.contentFrame) { 31 | foreground() 32 | } 33 | } 34 | } 35 | .readGeometry(size: $size) 36 | .onAppear { 37 | gestureCanvas.animationDuration = pixelCanvas.options.animationDuration 38 | gestureCanvas.minimumScale = 0.1 39 | gestureCanvas.maximumScale = nil 40 | gestureCanvas.delegate = pixelCanvas 41 | } 42 | .onChange(of: gestureCanvas.coordinate) { _, newCoordinate in 43 | pixelCanvas.coordinate = newCoordinate 44 | pixelCanvas.reFrame() 45 | } 46 | .onChange(of: size) { _, newSize in 47 | pixelCanvas.containerSize = newSize 48 | pixelCanvas.reFrame() 49 | } 50 | .onReceive(pixelCanvas.zoomCoordinateOffsetUpdate) { offset in 51 | gestureCanvas.zoomCoordinateOffset = offset 52 | } 53 | .onChange(of: pixelCanvas.content?.resolution) { _, resolution in 54 | if let resolution: CGSize { 55 | gestureCanvas.maximumScale = max(resolution.width, resolution.height) / 2 56 | } else { 57 | gestureCanvas.maximumScale = nil 58 | } 59 | } 60 | .onReceive(pixelCanvas.canvasZoom) { zoom in 61 | gestureCanvas.move(to: zoom.coordinate, animated: zoom.animated) 62 | } 63 | } 64 | 65 | @ViewBuilder 66 | private var pixelBody: some View { 67 | if let content: PixelCanvas.Content = pixelCanvas.content { 68 | if !pixelCanvas.options.alwaysUseImageCanvas { 69 | PixelCanvasZoomView( 70 | image: content.image, 71 | transform: PixelCanvas.transform( 72 | contentResolution: content.resolution, 73 | containerSize: size, 74 | coordinate: gestureCanvas.coordinate 75 | ), 76 | options: pixelCanvas.options 77 | ) 78 | .id(content.id) 79 | } else { 80 | PixelCanvasLayout(frame: pixelCanvas.contentFrame) { 81 | content.image 82 | .resizable() 83 | .aspectRatio(contentMode: .fit) 84 | } 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/PixelCanvas/Zoom/PixelCanvasZoomShader.metal: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | using namespace metal; 5 | 6 | float2 place(int place, 7 | float2 uv, 8 | uint leadingWidth, 9 | uint leadingHeight, 10 | uint trailingWidth, 11 | uint trailingHeight) { 12 | 13 | float aspect_a = float(leadingWidth) / float(leadingHeight); 14 | float aspect_b = float(trailingWidth) / float(trailingHeight); 15 | 16 | float u = uv.x; 17 | float v = uv.y; 18 | 19 | switch (place) { 20 | case 0: // Stretch 21 | break; 22 | case 1: // Aspect Fit 23 | if (aspect_b > aspect_a) { 24 | v /= aspect_a; 25 | v *= aspect_b; 26 | v += ((aspect_a - aspect_b) / 2) / aspect_a; 27 | } else if (aspect_b < aspect_a) { 28 | u /= aspect_b; 29 | u *= aspect_a; 30 | u += ((aspect_b - aspect_a) / 2) / aspect_b; 31 | } 32 | break; 33 | case 2: // Aspect Fill 34 | if (aspect_b > aspect_a) { 35 | u *= aspect_a; 36 | u /= aspect_b; 37 | u += ((1.0 / aspect_a - 1.0 / aspect_b) / 2) * aspect_a; 38 | } else if (aspect_b < aspect_a) { 39 | v *= aspect_b; 40 | v /= aspect_a; 41 | v += ((1.0 / aspect_b - 1.0 / aspect_a) / 2) * aspect_b; 42 | } 43 | break; 44 | case 3: // Fixed 45 | u = 0.5 + ((uv.x - 0.5) * leadingWidth) / trailingWidth; 46 | v = 0.5 + ((uv.y - 0.5) * leadingHeight) / trailingHeight; 47 | break; 48 | } 49 | 50 | return float2(u, v); 51 | } 52 | 53 | float2 uv(float2 position, float scale, float2 offset, float pixelsPerPoint, float2 containerResolution, float2 contentResolution, float2 textureResolution, int placement) { 54 | 55 | // Coordinate 56 | float2 uv = (position * pixelsPerPoint) / containerResolution; 57 | 58 | // Resolution 59 | uint inputWidth = contentResolution.x; 60 | uint inputHeight = contentResolution.y; 61 | uint outputWidth = containerResolution.x; 62 | uint outputHeight = containerResolution.y; 63 | 64 | // Placement 65 | float2 uvPlacement = place(placement, uv, outputWidth, outputHeight, inputWidth, inputHeight); 66 | float2 uvScale = float2(scale, scale); 67 | uvPlacement = (uvPlacement - 0.5) / uvScale + 0.5; 68 | uvPlacement -= offset / float2(outputWidth, outputHeight); 69 | 70 | return uvPlacement; 71 | } 72 | 73 | float checker(float2 uv, float checkerSize, uint2 resolution) { 74 | int x = int(uv.x * float(resolution.x)); 75 | x -= resolution.x / 2; 76 | int y = int(uv.y * float(resolution.y)); 77 | y -= resolution.y / 2; 78 | int big = int(checkerSize); 79 | while (big < 10000) { 80 | big *= 2; 81 | } 82 | bool isX = ((x + big) / int(checkerSize)) % 2 == 0; 83 | bool isY = ((y + big) / int(checkerSize)) % 2 == 0; 84 | float light = isX ? (isY ? 0.75 : 0.25) : (isY ? 0.25 : 0.75); 85 | return light; 86 | } 87 | 88 | [[ stitchable ]] half4 zoom(float2 position, 89 | SwiftUI::Layer layer, 90 | texture2d texture, 91 | float interpolate, 92 | float placement, 93 | float2 containerResolution, 94 | float2 contentResolution, 95 | float scale, 96 | float2 offset, 97 | float checkerTransparency, 98 | float checkerSize, 99 | float checkerOpacity, 100 | float borderWidth, 101 | float borderOpacity, 102 | float2 scaleRange, 103 | float pixelsPerPoint) { 104 | 105 | float2 textureResolution = float2(texture.get_width(), texture.get_height()); 106 | 107 | float2 uvPlacement = uv(position, scale, offset, pixelsPerPoint, containerResolution, contentResolution, textureResolution, int(placement)); 108 | if (uvPlacement.x < 0.0 || uvPlacement.x > 1.0 || uvPlacement.y < 0.0 || uvPlacement.y > 1.0) { 109 | return 0.0; 110 | } 111 | int2 location = int2(uvPlacement * textureResolution); 112 | 113 | float contentScale = max(contentResolution.x, contentResolution.y) / min(containerResolution.x, containerResolution.y); 114 | float thresholdScale = 2.0; 115 | bool zoomedIn = scale > (contentScale / thresholdScale); 116 | 117 | half4 color; 118 | if (interpolate > 0.0 && !zoomedIn) { 119 | half4 color1 = texture.read(uint2(location + int2(1, 1))); 120 | half4 color2 = texture.read(uint2(location + int2(1, 0))); 121 | half4 color3 = texture.read(uint2(location + int2(1, -1))); 122 | half4 color4 = texture.read(uint2(location + int2(0, 1))); 123 | half4 color5 = texture.read(uint2(location + int2(0, 0))); 124 | half4 color6 = texture.read(uint2(location + int2(0, -1))); 125 | half4 color7 = texture.read(uint2(location + int2(-1, 1))); 126 | half4 color8 = texture.read(uint2(location + int2(-1, 0))); 127 | half4 color9 = texture.read(uint2(location + int2(-1, -1))); 128 | color = (color1 + color2 + color3 + color4 + color5 + color6 + color7 + color8 + color9) / 9; 129 | } else { 130 | color = texture.read(uint2(location)); 131 | } 132 | 133 | // Checker 134 | if (checkerTransparency) { 135 | bool inBounds = false; 136 | float checkerLight = 0.0; 137 | if ((uvPlacement.x > 0.0 && uvPlacement.x < 1.0) && (uvPlacement.y > 0.0 && uvPlacement.y < 1.0)) { 138 | inBounds = true; 139 | if (scale < 1.0) { 140 | checkerLight = checker(uvPlacement, checkerSize, uint2(contentResolution.x, contentResolution.y)) * checkerOpacity; 141 | } else { 142 | float logScale = log2(scale); 143 | float logFraction = logScale - floor(logScale); 144 | float currentScalePower = pow(2.0, floor(logScale)); 145 | float nextScalePower = pow(2.0, floor(logScale) + 1.0); 146 | float currentSize = max(1.0, checkerSize / currentScalePower); 147 | float nextSize = max(1.0, checkerSize / nextScalePower); 148 | float currentChecker = checker(uvPlacement, currentSize, uint2(contentResolution.x, contentResolution.y)) * checkerOpacity; 149 | float nextChecker = checker(uvPlacement, nextSize, uint2(contentResolution.x, contentResolution.y)) * checkerOpacity; 150 | float fadeFraction = max(0.0, logFraction * 10.0 - 9.0); 151 | checkerLight = currentChecker * (1.0 - fadeFraction) + nextChecker * fadeFraction; 152 | } 153 | } 154 | color = half4(half3(checkerLight) * (1.0 - color.a) + color.rgb * color.a, 155 | inBounds ? 0.5 + 0.5 * color.a : 0.0); 156 | } 157 | 158 | // Border 159 | if (scale >= scaleRange.x && borderOpacity > 0.0 && borderWidth > 0.0) { 160 | float fraction = (scale - scaleRange.x) / (scaleRange.y - scaleRange.x); 161 | float zoomFade = min(max(fraction, 0.0), 1.0); 162 | float2 uvBorder = float2(borderWidth / scale, 163 | borderWidth / scale); 164 | float2 uvResolution = float2(uvPlacement.x * float(contentResolution.x), 165 | uvPlacement.y * float(contentResolution.y)); 166 | float2 uvPixel = float2(uvResolution.x - float(int(uvResolution.x)), 167 | uvResolution.y - float(int(uvResolution.y))); 168 | if (!(uvPixel.x > uvBorder.x && uvPixel.x < 1.0 - uvBorder.x) || !(uvPixel.y > uvBorder.y && uvPixel.y < 1.0 - uvBorder.y)) { 169 | float brightness = (color.r + color.g + color.b) / 3; 170 | float xPositiveFade = min(uvPixel.x / uvBorder.x, 1.0); 171 | float xNegativeFade = min((1.0 - uvPixel.x) / uvBorder.x, 1.0); 172 | float xFade = xPositiveFade * xNegativeFade; 173 | float yPositiveFade = min(uvPixel.y / uvBorder.y, 1.0); 174 | float yNegativeFade = min((1.0 - uvPixel.y) / uvBorder.y, 1.0); 175 | float yFade = yPositiveFade * yNegativeFade; 176 | float fade = xFade * yFade; 177 | float borderAlpha = borderOpacity * (1.0 - fade); 178 | half4 borderColor = half4(half3(brightness < 0.5 ? 1.0 : 0.0), borderAlpha * zoomFade); 179 | color = half4(color.rgb * (1.0 - borderColor.a) + borderColor.rgb * borderColor.a, max(color.a, borderColor.a)); 180 | } 181 | } 182 | 183 | return color; 184 | } 185 | -------------------------------------------------------------------------------- /Sources/PixelCanvas/Zoom/PixelCanvasZoomView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CoreGraphicsExtensions 3 | 4 | @available(iOS 17.0, macOS 14.0, visionOS 1.0, *) 5 | struct PixelCanvasZoomView: View { 6 | 7 | let image: Image 8 | let transform: PixelCanvas.Transform 9 | let options: PixelCanvas.Options 10 | 11 | private var shader: Shader { 12 | let function = ShaderFunction(library: .bundle(.module), name: "zoom") 13 | return Shader(function: function, arguments: [ 14 | .image(image), 15 | .float(options.interpolate ? 1.0 : 0.0), 16 | .float(CGFloat(options.placement.rawValue)), 17 | .float2(transform.containerResolution), 18 | .float2(transform.contentResolution), 19 | .float(transform.scale), 20 | .float2(transform.offset), 21 | .float(options.checkerTransparency ? 1.0 : 0.0), 22 | .float(options.checkerSize), 23 | .float(options.checkerOpacity), 24 | .float(options.borderWidth), 25 | .float(options.borderOpacity), 26 | .float2(CGPoint(x: options.borderFadeRange.lowerBound, 27 | y: options.borderFadeRange.upperBound)), 28 | .float(CGFloat.pixelsPerPoint) 29 | ]) 30 | } 31 | 32 | var body: some View { 33 | Rectangle() 34 | .layerEffect(shader, maxSampleOffset: .zero, isEnabled: true) 35 | } 36 | } 37 | --------------------------------------------------------------------------------