Similar solution in SwiftUI, Swift 5
I banged my head on this for a long, long time, but finally have a solution that works.
I've not been coding in Swift or SwiftUI for long, and I absolutely welcome comments to improve this code. In some places, I've left in a bit of Debugging code you can uncomment. Wrapping my head around the math involved more trial and error than competent, well-thought out approaches!
Shortcomings in this code: first, it would be nice to open the Impage picker from ContentView() and then show my custom view. I don't know how to do that. Second, if there is already an image in the ContentView(), it would be nice to populate the image in the custom view. However, then the user may expect to be able to get the "original" image and move it and scale it. That would require more than is needed for this answer. Ie, do you want to save the original photo in some url / application folder as well as the cropped version? Or even save a dictionary with the original picture and the CGRect needed to recreate the cropped view? Third. there seems to be a bug if the selected photo is exactly the size of the screen (a screenshot); it can be scaled too low.
In a new SwiftUI lifecycle app, I have the following SwiftUI views:
This is what you'll get:

I also use this crucial solution for cropping:
Finally, some of my code accesses system UIcolors so I use the extension in
ContentView
import SwiftUI
struct ContentView: View {
@State private var isShowingPhotoSelectionSheet = false
@State private var finalImage: UIImage?
@State private var inputImage: UIImage?
var body: some View {
VStack {
if finalImage != nil {
Image(uiImage: finalImage!)
.resizable()
.frame(width: 100, height: 100)
.scaledToFill()
.aspectRatio(contentMode: .fit)
.clipShape(Circle())
.shadow(radius: 4)
} else {
Image(systemName: "person.crop.circle.fill")
.resizable()
.scaledToFill()
.frame(width: 100, height: 100)
.aspectRatio(contentMode: .fit)
.foregroundColor(.systemGray2)
}
Button (action: {
self.isShowingPhotoSelectionSheet = true
}, label: {
Text("Change photo")
.foregroundColor(.systemRed)
.font(.footnote)
})
}
.background(Color.systemBackground)
.statusBar(hidden: isShowingPhotoSelectionSheet)
.fullScreenCover(isPresented: $isShowingPhotoSelectionSheet, onDismiss: loadImage) {
ImageMoveAndScaleSheet(croppedImage: $finalImage)
}
}
func loadImage() {
guard let inputImage = inputImage else { return }
finalImage = inputImage
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Clicking / tapping on Change photo
brings up the next view:
ImagemoveAndScaleSheet
This is a fullscreen modal which hides the statusbar while open.
import SwiftUI
struct ImageMoveAndScaleSheet: View {
@Environment(\.presentationMode) var presentationMode
@State private var isShowingImagePicker = false
///The croped image is what will will send back to the parent view.
///It should be the part of the image in the square defined by the
///cutout circle's diamter. See below, the cutout circle has an "inset" value
///which can be changed.
@Binding var croppedImage: UIImage?
///The input image is received from the ImagePicker.
///We will need to calculate and refer to its aspectr ratio in the functions.
@State private var inputImage: UIImage?
@State private var inputW: CGFloat = 750.5556577
@State private var inputH: CGFloat = 1336.5556577
@State private var theAspectRatio: CGFloat = 0.0
///The profileImage is what wee see on this view. When added from the
///ImapgePicker, it will be sized to fit the screen,
///meaning either its width will match the width of the device's screen,
///or its height will match the height of the device screen.
///This is not suitable for landscape mode or for iPads.
@State private var profileImage: Image?
@State private var profileW: CGFloat = 0.0
@State private var profileH: CGFloat = 0.0
///Zoom and Drag ...
@State private var currentAmount: CGFloat = 0
@State private var finalAmount: CGFloat = 1
@State private var currentPosition: CGSize = .zero
@State private var newPosition: CGSize = .zero
///We track of amount the image is moved for use in functions below.
@State private var horizontalOffset: CGFloat = 0.0
@State private var verticalOffset: CGFloat = 0.0
var body: some View {
ZStack {
ZStack {
Color.black.opacity(0.8)
if profileImage != nil {
profileImage?
.resizable()
.scaleEffect(finalAmount + currentAmount)
.scaledToFill()
.aspectRatio(contentMode: .fit)
.offset(x: self.currentPosition.width, y: self.currentPosition.height)
} else {
Image(systemName: "person.crop.circle.fill")
.resizable()
.scaleEffect(finalAmount + currentAmount)
.scaledToFill()
.aspectRatio(contentMode: .fit)
.foregroundColor(.systemGray2)
}
}
Rectangle()
.fill(Color.black).opacity(0.55)
.mask(HoleShapeMask().fill(style: FillStyle(eoFill: true)))
VStack {
Text((profileImage != nil) ? "Move and Scale" : "Select a Photo by tapping the icon below")
.foregroundColor(.white)
.padding(.top, 50)
Spacer()
HStack{
ZStack {
HStack {
Button(
action: {presentationMode.wrappedValue.dismiss()},
label: { Text("Cancel") })
Spacer()
Button(
action: {
self.save()
presentationMode.wrappedValue.dismiss()
})
{ Text("Save") }
.opacity((profileImage != nil) ? 1.0 : 0.2)
.disabled((profileImage != nil) ? false: true)
}
.padding(.horizontal)
.foregroundColor(.white)
Image(systemName: "circle.fill")
.font(.custom("system", size: 45))
.opacity(0.9)
.foregroundColor(.white)
Image(systemName: "photo.on.rectangle")
.imageScale(.medium)
.foregroundColor(.black)
.onTapGesture {
isShowingImagePicker = true
}
}
.padding(.bottom, 5)
}
}
.padding()
}
.edgesIgnoringSafeArea(.all)
//MARK: - Gestures
.gesture(
MagnificationGesture()
.onChanged { amount in
self.currentAmount = amount - 1
// repositionImage()
}
.onEnded { amount in
self.finalAmount += self.currentAmount
self.currentAmount = 0
repositionImage()
}
)
.simultaneousGesture(
DragGesture()
.onChanged { value in
self.currentPosition = CGSize(width: value.translation.width + self.newPosition.width, height: value.translation.height + self.newPosition.height)
}
.onEnded { value in
self.currentPosition = CGSize(width: value.translation.width + self.newPosition.width, height: value.translation.height + self.newPosition.height)
self.newPosition = self.currentPosition
repositionImage()
}
)
.simultaneousGesture(
TapGesture(count: 2)
.onEnded({
resetImageOriginAndScale()
})
)
.sheet(isPresented: $isShowingImagePicker, onDismiss: loadImage) {
ImagePicker(image: self.$inputImage)
.accentColor(Color.systemRed)
}
}
//MARK: - functions
private func HoleShapeMask() -> Path {
let rect = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
let insetRect = CGRect(x: inset, y: inset, width: UIScreen.main.bounds.width - ( inset * 2 ), height: UIScreen.main.bounds.height - ( inset * 2 ))
var shape = Rectangle().path(in: rect)
shape.addPath(Circle().path(in: insetRect))
return shape
}
///Called when the ImagePicker is dismissed.
///We want to measure the image receoived and determine the aspect ratio.
private func loadImage() {
guard let inputImage = inputImage else { return }
let w = inputImage.size.width
let h = inputImage.size.height
profileImage = Image(uiImage: inputImage)
inputW = w
inputH = h
theAspectRatio = w / h
resetImageOriginAndScale()
}
///The profileImage will size to fit the screen.
///But we need to know the width and height
///to set the related @State variables.
///Douobke-tpping the image will also set it
///as it was sized originally upon loading.
private func resetImageOriginAndScale() {
withAnimation(.easeInOut){
if theAspectRatio >= screenAspect {
profileW = UIScreen.main.bounds.width
profileH = profileW / theAspectRatio
} else {
profileH = UIScreen.main.bounds.height
profileW = profileH * theAspectRatio
}
currentAmount = 0
finalAmount = 1
currentPosition = .zero
newPosition = .zero
}
}
private func repositionImage() {
//Screen width
let w = UIScreen.main.bounds.width
if theAspectRatio > screenAspect {
profileW = UIScreen.main.bounds.width * finalAmount
profileH = profileW / theAspectRatio
} else {
profileH = UIScreen.main.bounds.height * finalAmount
profileW = profileH * theAspectRatio
}
horizontalOffset = (profileW - w ) / 2
verticalOffset = ( profileH - w ) / 2
///Keep the user from zooming too far in. Adjust as required by the individual project.
if finalAmount > 4.0 {
withAnimation{
finalAmount = 4.0
}
}
///The following if statements keep the image filling the circle cutout.
if profileW >= UIScreen.main.bounds.width {
if newPosition.width > horizontalOffset {
withAnimation(.easeInOut) {
newPosition = CGSize(width: horizontalOffset + inset, height: newPosition.height)
currentPosition = CGSize(width: horizontalOffset + inset, height: currentPosition.height)
}
}
if newPosition.width < ( horizontalOffset * -1) {
withAnimation(.easeInOut){
newPosition = CGSize(width: ( horizontalOffset * -1) - inset, height: newPosition.height)
currentPosition = CGSize(width: ( horizontalOffset * -1 - inset), height: currentPosition.height)
}
}
} else {
withAnimation(.easeInOut) {
newPosition = CGSize(width: 0, height: newPosition.height)
currentPosition = CGSize(width: 0, height: newPosition.height)
}
}
if profileH >= UIScreen.main.bounds.width {
if newPosition.height > verticalOffset {
withAnimation(.easeInOut){
newPosition = CGSize(width: newPosition.width, height: verticalOffset + inset)
currentPosition = CGSize(width: newPosition.width, height: verticalOffset + inset)
}
}
if newPosition.height < ( verticalOffset * -1) {
withAnimation(.easeInOut){
newPosition = CGSize(width: newPosition.width, height: ( verticalOffset * -1) - inset)
currentPosition = CGSize(width: newPosition.width, height: ( verticalOffset * -1) - inset)
}
}
} else {
withAnimation (.easeInOut){
newPosition = CGSize(width: newPosition.width, height: 0)
currentPosition = CGSize(width: newPosition.width, height: 0)
}
}
if profileW <= UIScreen.main.bounds.width && theAspectRatio > screenAspect {
resetImageOriginAndScale()
}
if profileH <= UIScreen.main.bounds.height && theAspectRatio < screenAspect {
resetImageOriginAndScale()
}
}
private func save() {
let scale = (inputImage?.size.width)! / profileW
let xPos = ( ( ( profileW - UIScreen.main.bounds.width ) / 2 ) + inset + ( currentPosition.width * -1 ) ) * scale
let yPos = ( ( ( profileH - UIScreen.main.bounds.width ) / 2 ) + inset + ( currentPosition.height * -1 ) ) * scale
let radius = ( UIScreen.main.bounds.width - inset * 2 ) * scale
croppedImage = imageWithImage(image: inputImage!, croppedTo: CGRect(x: xPos, y: yPos, width: radius, height: radius))
///Debug maths
print("Input: w \(inputW) h \(inputH)")
print("Profile: w \(profileW) h \(profileH)")
print("X Origin: \( ( ( profileW - UIScreen.main.bounds.width - inset ) / 2 ) + ( currentPosition.width * -1 ) )")
print("Y Origin: \( ( ( profileH - UIScreen.main.bounds.width - inset) / 2 ) + ( currentPosition.height * -1 ) )")
print("Scale: \(scale)")
print("Profile:\(profileW) + \(profileH)" )
print("Curent Pos: \(currentPosition.debugDescription)")
print("Radius: \(radius)")
print("x:\(xPos), y:\(yPos)")
}
let inset: CGFloat = 15
let screenAspect = UIScreen.main.bounds.width / UIScreen.main.bounds.height
}
Apart from the drag and scale gestures, the main things to look and (and probably clean up!) are the functions.
- HoleShapeMask() (cannot remember where that code is, but I know I got it on SO.
- repositionImage() (much headbanging here)
- save() which uses the funciton in the
ImageManipulation.swift
file.
ImagePicker
Again, this is simply from Hacking With Swift. (Thanks Paul!) https://twitter.com/twostraws/
import SwiftUI
struct ImagePicker: UIViewControllerRepresentable {
@Environment(\.presentationMode) var presentationMode
@Binding var image: UIImage?
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let uiImage = info[.originalImage] as? UIImage {
parent.image = uiImage
}
parent.presentationMode.wrappedValue.dismiss()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
}
}
ImageManipulation.swift
This contains the following code:
import UIKit
func imageWithImage(image: UIImage, croppedTo rect: CGRect) -> UIImage {
UIGraphicsBeginImageContext(rect.size)
let context = UIGraphicsGetCurrentContext()
let drawRect = CGRect(x: -rect.origin.x, y: -rect.origin.y,
width: image.size.width, height: image.size.height)
context?.clip(to: CGRect(x: 0, y: 0,
width: rect.size.width, height: rect.size.height))
image.draw(in: drawRect)
let subImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return subImage!
}
## Colors.swift ##
A handy extension to access system UIColors in SwiftUI:
import Foundation
import SwiftUI
extension Color {
static var label: Color {
return Color(UIColor.label)
}
static var secondaryLabel: Color {
return Color(UIColor.secondaryLabel)
}
static var tertiaryLabel: Color {
return Color(UIColor.tertiaryLabel)
}
static var quaternaryLabel: Color {
return Color(UIColor.quaternaryLabel)
}
static var systemFill: Color {
return Color(UIColor.systemFill)
}
static var secondarySystemFill: Color {
return Color(UIColor.secondarySystemFill)
}
static var tertiarySystemFill: Color {
return Color(UIColor.tertiarySystemFill)
}
static var quaternarySystemFill: Color {
return Color(UIColor.quaternarySystemFill)
}
static var systemBackground: Color {
return Color(UIColor.systemBackground)
}
static var secondarySystemBackground: Color {
return Color(UIColor.secondarySystemBackground)
}
static var tertiarySystemBackground: Color {
return Color(UIColor.tertiarySystemBackground)
}
static var systemGroupedBackground: Color {
return Color(UIColor.systemGroupedBackground)
}
static var secondarySystemGroupedBackground: Color {
return Color(UIColor.secondarySystemGroupedBackground)
}
static var tertiarySystemGroupedBackground: Color {
return Color(UIColor.tertiarySystemGroupedBackground)
}
static var systemRed: Color {
return Color(UIColor.systemRed)
}
static var systemBlue: Color {
return Color(UIColor.systemBlue)
}
static var systemPink: Color {
return Color(UIColor.systemPink)
}
static var systemTeal: Color {
return Color(UIColor.systemTeal)
}
static var systemGreen: Color {
return Color(UIColor.systemGreen)
}
static var systemIndigo: Color {
return Color(UIColor.systemIndigo)
}
static var systemOrange: Color {
return Color(UIColor.systemOrange)
}
static var systemPurple: Color {
return Color(UIColor.systemPurple)
}
static var systemYellow: Color {
return Color(UIColor.systemYellow)
}
static var systemGray: Color {
return Color(UIColor.systemGray)
}
static var systemGray2: Color {
return Color(UIColor.systemGray2)
}
static var systemGray3: Color {
return Color(UIColor.systemGray3)
}
static var systemGray4: Color {
return Color(UIColor.systemGray4)
}
static var systemGray5: Color {
return Color(UIColor.systemGray5)
}
static var systemGray6: Color {
return Color(UIColor.systemGray6)
}
}