Created
July 26, 2019 21:03
-
-
Save cyrilzakka/839568cda2fd4a41261f964ce6c1d56f to your computer and use it in GitHub Desktop.
Simple Image Viewer using SwiftUI
This file contains 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 | |
// 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") | |
} | |
} |
This file contains 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
// | |
// 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 }) | |
} | |
} |
This file contains 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
// | |
// 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) | |
} | |
} |
This file contains 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
// | |
// 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