Skip to content

Commit

Permalink
Allow 3rd party apps to inject their existing image editors to the Qu…
Browse files Browse the repository at this point in the history
…ickEditor (#375)
  • Loading branch information
pinarol authored Sep 2, 2024
1 parent a2e4259 commit 026b821
Show file tree
Hide file tree
Showing 10 changed files with 203 additions and 62 deletions.
18 changes: 16 additions & 2 deletions Demo/Demo/Gravatar-SwiftUI-Demo/DemoAvatarPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct DemoAvatarPickerView: View {

// You can make this `true` by default to easily test the picker
@State private var isPresentingPicker: Bool = false
@State var enableCustomImageCropper: Bool = false

var body: some View {
VStack(alignment: .leading, spacing: 5) {
Expand Down Expand Up @@ -42,19 +43,32 @@ struct DemoAvatarPickerView: View {
}
.pickerStyle(MenuPickerStyle())
}
Toggle("Custom image cropper", isOn: $enableCustomImageCropper)

Button("Tap to open the Avatar Picker") {
isPresentingPicker = true
}
.avatarPickerSheet(isPresented: $isPresentingPicker,
email: email,
authToken: token,
contentLayout: contentLayout)
authToken: token,
contentLayout: contentLayout,
customImageEditor: customImageEditor())
Spacer()
}
.padding(.horizontal)
}
}

func customImageEditor() -> ImageEditorBlock<TestImageCropper>? {
if enableCustomImageCropper {
let block = { image, editingDidFinish in
TestImageCropper(inputImage: image, editingDidFinish: editingDidFinish)
}
return block
}
return nil as ImageEditorBlock<TestImageCropper>?
}

@ViewBuilder
func tokenField() -> some View {
if isSecure {
Expand Down
37 changes: 37 additions & 0 deletions Demo/Demo/Gravatar-SwiftUI-Demo/TestImageCropper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import SwiftUI
import GravatarUI

struct TestImageCropper: View, ImageEditorView {
var inputImage: UIImage
var editingDidFinish: ((UIImage) -> Void)

init(inputImage: UIImage, editingDidFinish: @escaping (UIImage) -> Void) {
self.inputImage = inputImage
self.editingDidFinish = editingDidFinish
}

var body: some View {
Text("This is a dummy image cropper for solely test purposes. It doesn't do anything. It just passes the image as it is when the button is tapped.")
.padding()
Image(uiImage: inputImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 100)
Button(action: cropImage) {
Text("Crop Image")
.foregroundColor(.white)
.padding()
.background(Color.blue)
.cornerRadius(10)
}
}

private func cropImage() {
editingDidFinish(inputImage)
}
}

#Preview {
TestImageCropper(inputImage: UIImage()) { _ in }
}

4 changes: 4 additions & 0 deletions Demo/Gravatar-Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
914AC0192BD7FF08005DA4A5 /* DemoBaseProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914AC0172BD7FF08005DA4A5 /* DemoBaseProfileViewController.swift */; };
914AC01A2BD7FF08005DA4A5 /* DemoProfilePresentationStylesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914AC0182BD7FF08005DA4A5 /* DemoProfilePresentationStylesViewController.swift */; };
914AC0202BDAAC3C005DA4A5 /* DemoRemoteSVGViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914AC01F2BDAAC3C005DA4A5 /* DemoRemoteSVGViewController.swift */; };
917DEEC92C7F639F00E43774 /* TestImageCropper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 917DEEC82C7F619100E43774 /* TestImageCropper.swift */; };
91956A522B6793AF00BF3CF0 /* SwitchWithLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91956A512B6793AF00BF3CF0 /* SwitchWithLabel.swift */; };
91956A542B67943A00BF3CF0 /* DemoUIImageViewExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91956A532B67943A00BF3CF0 /* DemoUIImageViewExtensionViewController.swift */; };
91B73B372C404F6E00E7D325 /* DemoAvatarPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B73B362C404F6E00E7D325 /* DemoAvatarPickerView.swift */; };
Expand Down Expand Up @@ -70,6 +71,7 @@
914AC0172BD7FF08005DA4A5 /* DemoBaseProfileViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoBaseProfileViewController.swift; sourceTree = "<group>"; };
914AC0182BD7FF08005DA4A5 /* DemoProfilePresentationStylesViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoProfilePresentationStylesViewController.swift; sourceTree = "<group>"; };
914AC01F2BDAAC3C005DA4A5 /* DemoRemoteSVGViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoRemoteSVGViewController.swift; sourceTree = "<group>"; };
917DEEC82C7F619100E43774 /* TestImageCropper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestImageCropper.swift; sourceTree = "<group>"; };
91956A512B6793AF00BF3CF0 /* SwitchWithLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchWithLabel.swift; sourceTree = "<group>"; };
91956A532B67943A00BF3CF0 /* DemoUIImageViewExtensionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoUIImageViewExtensionViewController.swift; sourceTree = "<group>"; };
91B73B362C404F6E00E7D325 /* DemoAvatarPickerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoAvatarPickerView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -120,6 +122,7 @@
49C5D6132B5B33E20067C2A8 /* Preview Content */,
49EFFB572C51AA3E0086589A /* Gravatar-SwiftUI-Demo.Base.xcconfig */,
3FD478222C51D7E20071B8B9 /* Gravatar-SwiftUI-Demo.Release.xcconfig */,
917DEEC82C7F619100E43774 /* TestImageCropper.swift */,
);
path = "Gravatar-SwiftUI-Demo";
sourceTree = "<group>";
Expand Down Expand Up @@ -398,6 +401,7 @@
9146A7AE2C3BD8F000E07C63 /* DemoAvatarView.swift in Sources */,
91B73B372C404F6E00E7D325 /* DemoAvatarPickerView.swift in Sources */,
49C5D60E2B5B33E20067C2A8 /* DemoApp.swift in Sources */,
917DEEC92C7F639F00E43774 /* TestImageCropper.swift in Sources */,
1E3FA2452C75E403002901F2 /* DemoProfileEditorView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
37 changes: 21 additions & 16 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,16 @@ public enum AvatarPickerContentLayout: String, CaseIterable, Identifiable {
}

@MainActor
struct AvatarPickerView: View {
private enum Constants {
static let horizontalPadding: CGFloat = .DS.Padding.double
static let lightModeShadowColor = Color(uiColor: UIColor.rgba(25, 30, 35, alpha: 0.2))
static let title: String = "Gravatar" // defined here to avoid translations
static let vStackVerticalSpacing: CGFloat = .DS.Padding.medium
static let emailBottomSpacing: CGFloat = .DS.Padding.double
}
struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
fileprivate typealias Constants = AvatarPicker.Constants
fileprivate typealias Localized = AvatarPicker.Localized

@ObservedObject var model: AvatarPickerViewModel
@State var contentLayout: AvatarPickerContentLayout = .vertical
@Environment(\.colorScheme) var colorScheme: ColorScheme
@Binding var isPresented: Bool
@State private var safariURL: URL?

var customImageEditor: ImageEditorBlock<ImageEditor>?
var tokenErrorHandler: (() -> Void)?

public var body: some View {
Expand Down Expand Up @@ -182,14 +177,15 @@ struct AvatarPickerView: View {
}

private func imagePicker(label: @escaping () -> some View) -> some View {
SystemImagePickerView(label: label) { image in
SystemImagePickerView(label: label, customEditor: customImageEditor) { image in
uploadImage(image)
}
}

private func uploadImage(_ image: UIImage) {
Task {
await model.upload(image)
// If there's a custom image editor, it should take care of squaring.
await model.upload(image, shouldSquareImage: customImageEditor == nil)
}
}

Expand All @@ -204,6 +200,7 @@ struct AvatarPickerView: View {
if contentLayout == .vertical {
AvatarGrid(
grid: model.grid,
customImageEditor: customImageEditor,
onAvatarTap: { avatar in
model.selectAvatar(with: avatar.id)
},
Expand Down Expand Up @@ -306,8 +303,16 @@ struct AvatarPickerView: View {

// MARK: - Localized Strings

extension AvatarPickerView {
private enum Localized {
private enum AvatarPicker {
enum Constants {
static let horizontalPadding: CGFloat = .DS.Padding.double
static let lightModeShadowColor = Color(uiColor: UIColor.rgba(25, 30, 35, alpha: 0.2))
static let title: String = "Gravatar" // defined here to avoid translations
static let vStackVerticalSpacing: CGFloat = .DS.Padding.medium
static let emailBottomSpacing: CGFloat = .DS.Padding.double
}

enum Localized {
static let buttonUploadImage = NSLocalizedString(
"AvatarPicker.ContentLoading.Success.ctaButtonTitle",
value: "Upload image",
Expand Down Expand Up @@ -450,14 +455,14 @@ extension AvatarPickerView {
profileModel: PreviewModel()
)

return AvatarPickerView(model: model, contentLayout: .horizontal, isPresented: .constant(true))
return AvatarPickerView<NoCustomEditor>(model: model, contentLayout: .horizontal, isPresented: .constant(true))
}

#Preview("Empty elements") {
AvatarPickerView(model: .init(avatarImageModels: [], profileModel: nil), isPresented: .constant(true))
AvatarPickerView<NoCustomEditor>(model: .init(avatarImageModels: [], profileModel: nil), isPresented: .constant(true))
}

#Preview("Load from network") {
/// Enter valid email and auth token.
AvatarPickerView(model: .init(email: .init(""), authToken: ""), isPresented: .constant(true))
AvatarPickerView<NoCustomEditor>(model: .init(email: .init(""), authToken: ""), isPresented: .constant(true))
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,10 @@ class AvatarPickerViewModel: ObservableObject {
}
}

func upload(_ image: UIImage) async {
func upload(_ image: UIImage, shouldSquareImage: Bool) async {
guard let authToken else { return }

let squareImage = image.squared()
let squareImage = shouldSquareImage ? image.squared() : image
let localID = UUID().uuidString

let localImageModel = AvatarImageModel(id: localID, source: .local(image: squareImage), isLoading: true)
Expand Down
29 changes: 29 additions & 0 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/ImageEditorView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation
import SwiftUI

/// Describes an image editor to be used after picking the image from the photo picker.
/// Caution: The output needs to be a square image; otherwise, the Gravatar backend will not accept it.
public protocol ImageEditorView: View {
/// The image to edit.
var inputImage: UIImage { get }

/// Callback to call when the editing is done. Pass the edited image here.
var editingDidFinish: (UIImage) -> Void { get set }
}

public typealias ImageEditorBlock<ImageEditor: ImageEditorView> = (UIImage, _ editingDidFinish: @escaping (UIImage) -> Void) -> ImageEditor

/// Because of how generics work, the compiler must resolve the image editor's concrete type.
/// When its value is `nil` though, the compiler can't resolve the concrete type, and it complains. This type here is used to make the compiler happy when the
/// passed value is `nil`.
public struct NoCustomEditor: ImageEditorView {
public var inputImage: UIImage
public var editingDidFinish: (UIImage) -> Void

public var body: some View {
EmptyView()
}
}

/// This exists for the same reason with `NoCustomEditor`.
public typealias NoCustomEditorBlock = (UIImage, _ editingDidFinish: @escaping (UIImage) -> Void) -> NoCustomEditor
84 changes: 60 additions & 24 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/SystemImagePickerView.swift
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import PhotosUI
import SwiftUI

struct SystemImagePickerView<Label>: View where Label: View {
struct SystemImagePickerView<Label, ImageEditor: ImageEditorView>: View where Label: View {
@ViewBuilder var label: () -> Label

var customEditor: ImageEditorBlock<ImageEditor>?
let onImageSelected: (UIImage) -> Void

var body: some View {
// NOTE: Here we can choose between legacy and new picker.
// So far, the new SwiftUI PhotosPicker only supports Photos library, no camera, and no cropping, so we are only using legacy for now.
// The interface (using a Label property as the element to open the picker) is the same as in the new SwiftUI picker,
// which will make it easy to change it later on.
ImagePicker(label: label, onImageSelected: onImageSelected)
ImagePicker(label: label, onImageSelected: onImageSelected, customEditor: customEditor)
}
}

private struct ImagePicker<Label>: View where Label: View {
private struct ImagePicker<Label, ImageEditor: ImageEditorView>: View where Label: View {
enum SourceType: CaseIterable, Identifiable {
case photoLibrary
case camera
Expand All @@ -30,6 +30,8 @@ private struct ImagePicker<Label>: View where Label: View {

@ViewBuilder var label: () -> Label
let onImageSelected: (UIImage) -> Void
var customEditor: ImageEditorBlock<ImageEditor>?
@State var imagePickerSelectedItem: ImagePickerItem?

var body: some View {
VStack {
Expand All @@ -48,20 +50,43 @@ private struct ImagePicker<Label>: View where Label: View {
}
.sheet(item: $sourceType, content: { source in
// This allows to present different kind of pickers for different sources.
switch source {
case .camera:
ZStack {
Color.black.ignoresSafeArea(edges: .all)
LegacyImagePickerRepresentable(sourceType: source.map()) { image in
onImageSelected(image)
displayImagePicker(for: source)
.sheet(item: $imagePickerSelectedItem, content: { item in
if let customEditor {
customEditor(item.image) { croppedImage in
imagePickerSelectedItem = nil
sourceType = nil
onImageSelected(croppedImage)
}
}
})
})
}

@ViewBuilder
private func displayImagePicker(for source: SourceType) -> some View {
switch source {
case .camera:
ZStack {
Color.black.ignoresSafeArea(edges: .all)
LegacyImagePickerRepresentable(sourceType: source.map(), useBuiltInCropper: customEditor == nil) { item in
pickerDidSelectImage(item)
}
case .photoLibrary:
LegacyImagePickerRepresentable(sourceType: source.map()) { image in
onImageSelected(image)
}.ignoresSafeArea()
}
})
case .photoLibrary:
LegacyImagePickerRepresentable(sourceType: source.map(), useBuiltInCropper: customEditor == nil) { item in
pickerDidSelectImage(item)
}.ignoresSafeArea()
}
}

private func pickerDidSelectImage(_ item: ImagePickerItem) {
if customEditor != nil {
imagePickerSelectedItem = item
} else {
sourceType = nil
onImageSelected(item.image)
}
}
}

Expand Down Expand Up @@ -96,19 +121,20 @@ struct LegacyImagePickerRepresentable: UIViewControllerRepresentable {
@Environment(\.presentationMode) private var presentationMode

var sourceType: UIImagePickerController.SourceType
let onImageSelected: (UIImage) -> Void
var useBuiltInCropper: Bool
let onImageSelected: (ImagePickerItem) -> Void

@State private var selectedUIImage: UIImage? {
@State private var pickedImage: ImagePickerItem? {
didSet {
if let selectedUIImage {
onImageSelected(selectedUIImage)
if let pickedImage {
onImageSelected(pickedImage)
}
}
}

func makeUIViewController(context: UIViewControllerRepresentableContext<LegacyImagePickerRepresentable>) -> UIImagePickerController {
let imagePicker = UIImagePickerController()
imagePicker.allowsEditing = true
imagePicker.allowsEditing = useBuiltInCropper
imagePicker.sourceType = sourceType
imagePicker.delegate = context.coordinator

Expand All @@ -132,11 +158,21 @@ struct LegacyImagePickerRepresentable: UIViewControllerRepresentable {
}

func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[UIImagePickerController.InfoKey.editedImage] as? UIImage {
parent.selectedUIImage = image
guard let url = info[UIImagePickerController.InfoKey.imageURL] as? URL else { return }
if picker.allowsEditing, let image = info[UIImagePickerController.InfoKey.editedImage] as? UIImage {
parent.pickedImage = .init(image: image, url: url)
} else if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
parent.pickedImage = .init(image: image, url: url)
}

parent.presentationMode.wrappedValue.dismiss()
}
}
}

struct ImagePickerItem: Identifiable {
var id: String {
url.absoluteString
}

let image: UIImage
let url: URL
}
Loading

0 comments on commit 026b821

Please sign in to comment.