Skip to content

Instantly share code, notes, and snippets.

@cyrilzakka
Created July 26, 2019 21:03
Show Gist options
  • Save cyrilzakka/839568cda2fd4a41261f964ce6c1d56f to your computer and use it in GitHub Desktop.
Save cyrilzakka/839568cda2fd4a41261f964ce6c1d56f to your computer and use it in GitHub Desktop.
Simple Image Viewer using SwiftUI
//
// ContentView.swift
// Scribe
//
// Created by Cyril Zakka on 7/21/19.
// Copyright © 2019 Cyril Zakka. All rights reserved.
//
import SwiftUI
struct ContentView: View {
@State var sourceRect: CGRect = .zero
@State var selectedImage: ImageData = ImageData()
let imageViewerAnimatorBindings = ImageViewerAnimatorBindings()
var body: some View {
return ZStack(alignment: .topLeading) {
ImageView(sourceRect: $sourceRect, selectedImage: $selectedImage, imageName: "wrist", height: 200, cornerRadius: 20)
.padding()
.environmentObject(imageViewerAnimatorBindings)
ImageViewAnimator(sourceRect: sourceRect, selectedImage: selectedImage)
.environmentObject(imageViewerAnimatorBindings)
}
.environmentObject(imageViewerAnimatorBindings)
.coordinateSpace(name: "globalCooardinate")
}
}
//
// ImageView.swift
// Scribe
//
// Created by Cyril Zakka on 7/21/19.
// Copyright © 2019 Cyril Zakka. All rights reserved.
//
import SwiftUI
/// A struct responsible for holding `ImageView` metadata.
struct ImageData: Codable, Identifiable {
let id = UUID()
var imageName: String = "wrist"
var cornerRadius: Length = 0
}
/// Sets a `PreferenceKey` for the `CGRect` of an `ImageView`.
/// For more information, read the following [post](https://swiftui-lab.com/communicating-with-the-view-tree-part-1/).
struct CGRectPreferenceKey: PreferenceKey {
static var defaultValue = CGRect.zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
typealias Value = CGRect
}
/// A view responsible for fetching the `CGSize` and `CGRect` of an `ImageView`.
/// For more information, read the following [post](https://swiftui-lab.com/communicating-with-the-view-tree-part-1/).
struct ImageViewGeometry: View {
var body: some View {
GeometryReader { reader in
return Rectangle()
.fill(Color.clear)
.preference(key: CGRectPreferenceKey.self, value: reader.frame(in: .named("globalCooardinate")))
}
}
}
/// A view responsible for displaying an image.
struct ImageView: View {
@EnvironmentObject var imageViewerAnimatorBindings: ImageViewerAnimatorBindings
@Binding var sourceRect: CGRect
@Binding var selectedImage: ImageData
var imageName: String
var width: Length?
var height: Length?
var cornerRadius: Length = 0
var body: some View {
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: width, height: height, alignment: .center)
.opacity(self.imageViewerAnimatorBindings.shouldAnimateTransition ? 0:1)
.animation(Animation.linear(duration: self.imageViewerAnimatorBindings.shouldAnimateTransition ? 0.05:0.1).delay(self.imageViewerAnimatorBindings.shouldAnimateTransition ? 0:0.3))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.background(ImageViewGeometry()).tapAction {
self.selectedImage = ImageData(imageName: self.imageName, cornerRadius: self.cornerRadius)
self.imageViewerAnimatorBindings.shouldAnimateTransition = true
}
.onPreferenceChange(CGRectPreferenceKey.self, perform: { self.sourceRect = $0 })
}
}
//
// ImageViewerHelper.swift
// Scribe
//
// Created by Cyril Zakka on 7/21/19.
// Copyright © 2019 Cyril Zakka. All rights reserved.
//
import SwiftUI
import Combine
/// A binding responsible for propagating animation information for `ImageViewAnimator`.
class ImageViewerAnimatorBindings: BindableObject {
let willChange = PassthroughSubject<Void, Never>()
var shouldAnimateTransition: Bool = false {
willSet { willChange.send() }
}
}
/// A view responsible for animating the transition from `ImageView` to `InteractiveImageView`.
struct ImageViewAnimator: View {
@EnvironmentObject var imageViewerAnimatorBindings: ImageViewerAnimatorBindings
@State var dragOffset: CGSize = .zero
var sourceRect: CGRect
var selectedImage: ImageData
var body: some View {
ZStack(alignment: self.imageViewerAnimatorBindings.shouldAnimateTransition ? .center:.topLeading) {
Rectangle()
.opacity(
self.dragOffset.height != .zero ? Double(max(1 - abs(self.dragOffset.height)*0.004, 0.6)):self.imageViewerAnimatorBindings.shouldAnimateTransition ? 1:0
)
.animation(.linear)
InteractiveImageView(dragOffset: $dragOffset, selectedImage: selectedImage, sourceRect: sourceRect)
.aspectRatio(contentMode: self.imageViewerAnimatorBindings.shouldAnimateTransition ? .fit:.fill)
.frame(width: self.imageViewerAnimatorBindings.shouldAnimateTransition ? nil:sourceRect.width, height: self.imageViewerAnimatorBindings.shouldAnimateTransition ? nil:sourceRect.height, alignment: .center)
.offset(x: self.imageViewerAnimatorBindings.shouldAnimateTransition ? 0:sourceRect.origin.x, y: self.imageViewerAnimatorBindings.shouldAnimateTransition ? 0:sourceRect.origin.y + 42)
// TODO: Find a way to get `.edgesIgnoringSafeArea(.all)` offset programatically instead
}
.opacity(self.imageViewerAnimatorBindings.shouldAnimateTransition ? 1:0)
.animation(self.imageViewerAnimatorBindings.shouldAnimateTransition ? nil:Animation.linear(duration:0.2).delay(0.4))
.edgesIgnoringSafeArea(.all)
}
}
//
// ImageViewer.swift
// Scribe
//
// Created by Cyril Zakka on 7/21/19.
// Copyright © 2019 Cyril Zakka. All rights reserved.
//
import SwiftUI
/// A view responsible for creating the now-standard image viewer on iOS. Enables zooming, panning, action sheet presentation and swipe-to-dismiss.
struct InteractiveImageView: View {
// Environment Object
@EnvironmentObject var imageViewerAnimatorBindings: ImageViewerAnimatorBindings
// Magnify and Rotate States
@State private var magScale: CGFloat = 1
@State private var rotAngle: Angle = .zero
@State private var isScaled: Bool = false
// Drag Gesture Binding
@Binding var dragOffset: CGSize
// Double Tap Gesture State
@State private var shouldFit: Bool = true
// Action Sheet State
@State var shouldShowActionSheet = false
// Image CGSize State
@State var imageSize: CGSize = .zero
var selectedImage: ImageData
var sourceRect: CGRect
var sheet: ActionSheet {
ActionSheet(title: Text("Image options"), message: nil, buttons: [
.default(Text("Save Image"), onTrigger: { self.shouldShowActionSheet = false }),
.destructive(Text("Delete Image"), onTrigger: { self.shouldShowActionSheet = false }),
.cancel({self.shouldShowActionSheet = false})
])
}
var body: some View {
// Gestures
let activateActionSheet = LongPressGesture()
.onEnded { _ in self.shouldShowActionSheet = true }
let rotateAndZoom = MagnificationGesture()
.onChanged {
self.magScale = $0
self.isScaled = true
}
.onEnded {
$0 > 1 ? (self.magScale = $0):(self.magScale = 1)
self.isScaled = $0 > 1
}
.simultaneously(with: RotationGesture()
.onChanged { self.rotAngle = $0 }
.onEnded { _ in self.rotAngle = .zero }
)
let dragOrDismiss = DragGesture()
.onChanged { self.dragOffset = $0.translation }
.onEnded { value in
if self.isScaled {
self.dragOffset = value.translation
} else {
if abs(self.dragOffset.height) > 100 {
self.imageViewerAnimatorBindings.shouldAnimateTransition = false
}
self.dragOffset = CGSize.zero
}
}
let fitToFill = TapGesture(count: 2)
.onEnded {
self.isScaled ? (self.shouldFit = true):(self.shouldFit = false)
self.isScaled.toggle()
if !self.isScaled {
self.magScale = 1
self.dragOffset = .zero
}
}
.exclusively(before: activateActionSheet)
.exclusively(before: dragOrDismiss)
.exclusively(before: rotateAndZoom)
return ZStack(alignment: .center) {
Image(selectedImage.imageName)
.resizable()
.renderingMode(.original)
.clipShape(RoundedRectangle(cornerRadius: self.imageViewerAnimatorBindings.shouldAnimateTransition ? 0:selectedImage.cornerRadius,
style: .continuous)
.size(
width: self.imageViewerAnimatorBindings.shouldAnimateTransition ? self.imageSize.width:sourceRect.width,
height: self.imageViewerAnimatorBindings.shouldAnimateTransition ? self.imageSize.height:sourceRect.height
)
.offset(x: 0, y: self.imageViewerAnimatorBindings.shouldAnimateTransition ? 0:yOffset(sizeOfImage: self.imageSize, targetMaskSize: self.sourceRect.size))
)
.gesture(fitToFill)
.scaleEffect(isScaled ? magScale: max(1 - abs(self.dragOffset.height)*0.004, 0.6), anchor: .center)
.rotationEffect(rotAngle, anchor: .center)
.offset(x: dragOffset.width*magScale, y: dragOffset.height*magScale)
.background(ImageViewGeometry())
.onPreferenceChange(CGRectPreferenceKey.self, perform: { self.imageSize = $0.size })
.animation(.spring(response: 0.4, dampingFraction: 0.9))
}
.actionSheet(isPresented: $shouldShowActionSheet, content: { sheet })
}
func yOffset(sizeOfImage: CGSize, targetMaskSize: CGSize) -> CGFloat {
let midImage = sizeOfImage.height/2
let midMask = targetMaskSize.height/2
return midImage - midMask
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment