├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Package.swift ├── README.md └── Sources └── SwiftUIImageViewer └── SwiftUIImageViewer.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swiftui-image-viewer", 8 | platforms: [.iOS(.v15), .macOS(.v10_15)], 9 | products: [ 10 | .library(name: "SwiftUIImageViewer", targets: ["SwiftUIImageViewer"]), 11 | ], 12 | targets: [ 13 | .target(name: "SwiftUIImageViewer", dependencies: []), 14 | ] 15 | ) 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌅 SwiftUIImageViewer 2 | 3 | A fullscreen image viewer with pinch-zooming built using SwiftUI. 4 | 5 | ![image](https://media.giphy.com/media/68H9UkT4pYKXpnCkkj/giphy.gif) 6 | 7 | # 👩‍🔧 Installation 8 | 9 | File -> Swift Packages -> Add Package Dependancy.. 10 | 11 | ```Swift 12 | https://github.com/fuzzzlove/swiftui-image-viewer.git 13 | ``` 14 | 15 | # 👩‍💻 Usage 16 | 17 | ```Swift 18 | import SwiftUI 19 | import SwiftUIImageViewer 20 | 21 | struct ContentView: View { 22 | 23 | @State private var isImagePresented = false 24 | 25 | var body: some View { 26 | image 27 | .resizable() 28 | .scaledToFit() 29 | .cornerRadius(12) 30 | .frame(width: 260, height: 260) 31 | .onTapGesture { 32 | isImagePresented = true 33 | } 34 | .fullScreenCover(isPresented: $isImagePresented) { 35 | SwiftUIImageViewer(image: image) 36 | .overlay(alignment: .topTrailing) { 37 | closeButton 38 | } 39 | } 40 | } 41 | 42 | private var image: Image { 43 | Image("dogs") 44 | } 45 | 46 | private var closeButton: some View { 47 | Button { 48 | isImagePresented = false 49 | } label: { 50 | Image(systemName: "xmark") 51 | .font(.headline) 52 | } 53 | .buttonStyle(.bordered) 54 | .clipShape(Circle()) 55 | .tint(.purple) 56 | .padding() 57 | } 58 | } 59 | ``` 60 | 61 | -------------------------------------------------------------------------------- /Sources/SwiftUIImageViewer/SwiftUIImageViewer.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct SwiftUIImageViewer: View { 4 | 5 | let image: Image 6 | 7 | @State private var scale: CGFloat = 1 8 | @State private var lastScale: CGFloat = 1 9 | 10 | @State private var offset: CGPoint = .zero 11 | @State private var lastTranslation: CGSize = .zero 12 | 13 | public init(image: Image) { 14 | self.image = image 15 | } 16 | 17 | public var body: some View { 18 | GeometryReader { proxy in 19 | ZStack { 20 | image 21 | .resizable() 22 | .aspectRatio(contentMode: .fit) 23 | .scaleEffect(scale) 24 | .offset(x: offset.x, y: offset.y) 25 | .gesture(makeDragGesture(size: proxy.size)) 26 | .gesture(makeMagnificationGesture(size: proxy.size)) 27 | } 28 | .frame(maxWidth: .infinity, maxHeight: .infinity) 29 | .edgesIgnoringSafeArea(.all) 30 | } 31 | } 32 | 33 | private func makeMagnificationGesture(size: CGSize) -> some Gesture { 34 | MagnificationGesture() 35 | .onChanged { value in 36 | let delta = value / lastScale 37 | lastScale = value 38 | 39 | // To minimize jittering 40 | if abs(1 - delta) > 0.01 { 41 | scale *= delta 42 | } 43 | } 44 | .onEnded { _ in 45 | lastScale = 1 46 | if scale < 1 { 47 | withAnimation { 48 | scale = 1 49 | } 50 | } 51 | adjustMaxOffset(size: size) 52 | } 53 | } 54 | 55 | private func makeDragGesture(size: CGSize) -> some Gesture { 56 | DragGesture() 57 | .onChanged { value in 58 | let diff = CGPoint( 59 | x: value.translation.width - lastTranslation.width, 60 | y: value.translation.height - lastTranslation.height 61 | ) 62 | offset = .init(x: offset.x + diff.x, y: offset.y + diff.y) 63 | lastTranslation = value.translation 64 | } 65 | .onEnded { _ in 66 | adjustMaxOffset(size: size) 67 | } 68 | } 69 | 70 | private func adjustMaxOffset(size: CGSize) { 71 | let maxOffsetX = (size.width * (scale - 1)) / 2 72 | let maxOffsetY = (size.height * (scale - 1)) / 2 73 | 74 | var newOffsetX = offset.x 75 | var newOffsetY = offset.y 76 | 77 | if abs(newOffsetX) > maxOffsetX { 78 | newOffsetX = maxOffsetX * (abs(newOffsetX) / newOffsetX) 79 | } 80 | if abs(newOffsetY) > maxOffsetY { 81 | newOffsetY = maxOffsetY * (abs(newOffsetY) / newOffsetY) 82 | } 83 | 84 | let newOffset = CGPoint(x: newOffsetX, y: newOffsetY) 85 | if newOffset != offset { 86 | withAnimation { 87 | offset = newOffset 88 | } 89 | } 90 | self.lastTranslation = .zero 91 | } 92 | } 93 | --------------------------------------------------------------------------------