diff --git a/Dev/Sources/SwiftUIDemo/ContentView.swift b/Dev/Sources/SwiftUIDemo/ContentView.swift index 4ab2c3d8..15f8c2b2 100644 --- a/Dev/Sources/SwiftUIDemo/ContentView.swift +++ b/Dev/Sources/SwiftUIDemo/ContentView.swift @@ -3,7 +3,7 @@ import BrightroomUI import SwiftUI struct ContentView: View { - @State private var image: SwiftUI.Image? + @State private var renderedImage: SwiftUI.Image? @State private var sharedStack = Mocks.makeEditingStack(image: Mocks.imageHorizontal()) @State private var fullScreenView: FullscreenIdentifiableView? @@ -34,7 +34,7 @@ struct ContentView: View { NavigationView { VStack { Group { - if let image = image { + if let image = renderedImage { image .resizable() .aspectRatio(contentMode: .fit) @@ -49,12 +49,18 @@ struct ContentView: View { Section { Button("Component: Crop - keepAlive") { - fullScreenView = .init { DemoCropView(editingStack: sharedStack) } + fullScreenView = .init { + DemoCropView( + editingStack: sharedStack + ) + } } Button("Component: Crop") { fullScreenView = .init { - DemoCropView(editingStack: Mocks.makeEditingStack(image: Mocks.imageHorizontal())) + DemoCropView( + editingStack: Mocks.makeEditingStack(image: Mocks.imageHorizontal()) + ) } } } @@ -65,7 +71,8 @@ struct ContentView: View { SwiftUIPhotosCropView( editingStack: stackForHorizontal, onDone: { - self.image = try! stackForHorizontal.makeRenderer().render().swiftUIImage + self.renderedImage = try! stackForHorizontal.makeRenderer().render() + .swiftUIImage self.fullScreenView = nil }, onCancel: {} @@ -78,7 +85,7 @@ struct ContentView: View { SwiftUIPhotosCropView( editingStack: stackForVertical, onDone: { - self.image = try! stackForVertical.makeRenderer().render().swiftUIImage + self.renderedImage = try! stackForVertical.makeRenderer().render().swiftUIImage self.fullScreenView = nil }, onCancel: {} @@ -91,7 +98,7 @@ struct ContentView: View { SwiftUIPhotosCropView( editingStack: stackForSquare, onDone: { - self.image = try! stackForSquare.makeRenderer().render().swiftUIImage + self.renderedImage = try! stackForSquare.makeRenderer().render().swiftUIImage self.fullScreenView = nil }, onCancel: {} @@ -104,7 +111,7 @@ struct ContentView: View { SwiftUIPhotosCropView( editingStack: stackForNasa, onDone: { - self.image = try! stackForNasa.makeRenderer().render().swiftUIImage + self.renderedImage = try! stackForNasa.makeRenderer().render().swiftUIImage self.fullScreenView = nil }, onCancel: {} @@ -117,7 +124,7 @@ struct ContentView: View { SwiftUIPhotosCropView( editingStack: stackForSmall, onDone: { - self.image = try! stackForSmall.makeRenderer().render().swiftUIImage + self.renderedImage = try! stackForSmall.makeRenderer().render().swiftUIImage self.fullScreenView = nil }, onCancel: {} @@ -139,7 +146,7 @@ struct ContentView: View { SwiftUIPhotosCropView( editingStack: stack, onDone: { - self.image = try! stack.makeRenderer().render().swiftUIImage + self.renderedImage = try! stack.makeRenderer().render().swiftUIImage self.fullScreenView = nil }, onCancel: {} @@ -161,7 +168,7 @@ struct ContentView: View { SwiftUIPhotosCropView( editingStack: stack, onDone: { - self.image = try! stack.makeRenderer().render().swiftUIImage + self.renderedImage = try! stack.makeRenderer().render().swiftUIImage self.fullScreenView = nil }, onCancel: {} @@ -182,7 +189,7 @@ struct ContentView: View { ) fullScreenView = .init { PixelEditWrapper(editingStack: stack) { - self.image = try! stackForHorizontal.makeRenderer().render().swiftUIImage + self.renderedImage = try! stackForHorizontal.makeRenderer().render().swiftUIImage self.fullScreenView = nil } } @@ -194,7 +201,7 @@ struct ContentView: View { ) fullScreenView = .init { PixelEditWrapper(editingStack: stack) { - self.image = try! stackForHorizontal.makeRenderer().render().swiftUIImage + self.renderedImage = try! stackForHorizontal.makeRenderer().render().swiftUIImage self.fullScreenView = nil } } diff --git a/Dev/Sources/SwiftUIDemo/DemoCropView.swift b/Dev/Sources/SwiftUIDemo/DemoCropView.swift index 6bc1fad5..769b74bb 100644 --- a/Dev/Sources/SwiftUIDemo/DemoCropView.swift +++ b/Dev/Sources/SwiftUIDemo/DemoCropView.swift @@ -6,8 +6,8 @@ // Copyright © 2021 muukii. All rights reserved. // -import BrightroomUI import BrightroomEngine +import BrightroomUI import SwiftUI import UIKit @@ -16,7 +16,15 @@ struct DemoCropView: View { let editingStack: EditingStack @State var rotation: EditingCrop.Rotation? - @State var adjustmentAngle: EditingCrop.AdjustmentAngle = .zero + @State var adjustmentAngle: EditingCrop.AdjustmentAngle? + + @State var resultImage: ResultImage? + + init( + editingStack: EditingStack + ) { + self.editingStack = editingStack + } var body: some View { VStack { @@ -35,7 +43,8 @@ struct DemoCropView: View { Circle() .foregroundColor(.white) .frame(width: 50, height: 50, alignment: .center) - }) + } + ) ) .rotation(rotation) .adjustmentAngle(adjustmentAngle) @@ -55,27 +64,60 @@ struct DemoCropView: View { self.rotation = .angle_270 } Button("- 10") { - self.adjustmentAngle -= .degrees(10) + if self.adjustmentAngle == nil { + self.adjustmentAngle = .zero + } + self.adjustmentAngle! -= .degrees(10) } Button("+ 10") { - self.adjustmentAngle += .degrees(10) + if self.adjustmentAngle == nil { + self.adjustmentAngle = .zero + } + self.adjustmentAngle! += .degrees(10) } } } } Button("Done") { let image = try! editingStack.makeRenderer().render().swiftUIImage - print(image) + self.resultImage = .init(image: image) } } .onAppear { editingStack.start() } + .sheet(item: $resultImage) { + RenderedResultView(image: $0.image) + } + } +} + +struct ResultImage: Identifiable { + let id: String + let image: Image + + init(image: Image) { + self.id = UUID().uuidString + self.image = image } } +struct RenderedResultView: View { + + let image: Image + + var body: some View { + image + .resizable() + .aspectRatio(contentMode: .fit) + } + +} + #Preview { - DemoCropView(editingStack: Mocks.makeEditingStack(image: Mocks.imageHorizontal())) + DemoCropView( + editingStack: Mocks.makeEditingStack(image: Mocks.imageHorizontal()) + ) } #Preview { @@ -91,5 +133,3 @@ struct DemoCropView: View { print(uiView.frame, uiView.bounds) } } - - diff --git a/Sources/BrightroomEngine/Core/EditingCrop.swift b/Sources/BrightroomEngine/Core/EditingCrop.swift index 786884c2..579dcd52 100644 --- a/Sources/BrightroomEngine/Core/EditingCrop.swift +++ b/Sources/BrightroomEngine/Core/EditingCrop.swift @@ -130,7 +130,7 @@ public struct EditingCrop: Equatable { return new } - private func scaled(_ scale: CGFloat) -> Self { + private consuming func scaled(_ scale: CGFloat) -> Self { var modified = self diff --git a/Sources/BrightroomEngine/Library/Geometry.swift b/Sources/BrightroomEngine/Library/Geometry.swift index 532c5e2b..96f54f16 100644 --- a/Sources/BrightroomEngine/Library/Geometry.swift +++ b/Sources/BrightroomEngine/Library/Geometry.swift @@ -132,7 +132,7 @@ extension CGSize { } } -public struct PixelAspectRatio: Hashable { +public struct PixelAspectRatio: Hashable, CustomReflectable { public static func == (lhs: Self, rhs: Self) -> Bool { lhs._comparingValue == rhs._comparingValue @@ -261,5 +261,18 @@ public struct PixelAspectRatio: Hashable { .init(width: 1, height: 1) } + public var customMirror: Mirror { + + return Mirror( + self, + children: [ + "width": width, + "height": height, + "ratio": _comparingValue + ], + displayStyle:.struct + ) + } + } diff --git a/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift b/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift index b348bde5..6406a6c5 100644 --- a/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift +++ b/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift @@ -110,6 +110,8 @@ public final class CropView: UIView, UIScrollViewDelegate { insetOfGuideFlexibility: contentInset ) + private let guideBackdropView = UIView() + private var subscriptions = Set() /// A throttling timer to apply guide changed event. @@ -159,12 +161,14 @@ public final class CropView: UIView, UIScrollViewDelegate { super.init(frame: .zero) + guideBackdropView.isUserInteractionEnabled = false scrollBackdropView.accessibilityIdentifier = "scrollBackdropView" clipsToBounds = false addSubview(scrollBackdropView) addSubview(scrollView) + addSubview(guideBackdropView) addSubview(guideView) imageView.isUserInteractionEnabled = true @@ -393,6 +397,7 @@ public final class CropView: UIView, UIScrollViewDelegate { $0.proposedCrop?.rotation = rotation $0.layoutVersion += 1 } + } public func setAdjustmentAngle(_ angle: EditingCrop.AdjustmentAngle) { @@ -401,6 +406,7 @@ public final class CropView: UIView, UIScrollViewDelegate { $0.proposedCrop?.adjustmentAngle = angle $0.layoutVersion += 1 } + } public func setCrop(_ crop: EditingCrop) { @@ -562,7 +568,12 @@ extension CropView { scrollBackdropView.bounds.size = frame.size scrollBackdropView.center = .init(x: bounds.midX, y: bounds.midY) + guideBackdropView.transform = .identity + guideBackdropView.frame = contentRect + guideBackdropView.transform = crop.rotation.transform.rotated(by: crop.adjustmentAngle.radians) + guideView.frame = contentRect + scrollView.transform = crop.rotation.transform.rotated(by: crop.adjustmentAngle.radians) updateScrollViewInset(crop: crop) @@ -573,7 +584,8 @@ extension CropView { let (min, max) = crop.calculateZoomScale( visibleSize: guideView.bounds .applying(crop.rotation.transform) - .rotated(crop.adjustmentAngle.radians).size + .rotated(crop.adjustmentAngle.radians) + .size ) scrollView.minimumZoomScale = min @@ -585,6 +597,8 @@ extension CropView { scrollView.customZoom( to: crop.zoomExtent(visibleSize: guideView.bounds.size), + guideSize: guideView.bounds.size, +// adjustmentRotation: crop.adjustmentAngle.radians, animated: false ) @@ -711,19 +725,7 @@ extension CropView { updateScrollViewInset(crop: currentProposedCrop) - store.commit { state in - - let rect = guideView.convert(guideView.bounds, to: imageView) - let resolvedRect = state.proposedCrop?.makeCropExtent(rect: rect) ?? .zero - - // TODO: Might cause wrong cropping if set the invalid size or origin. For example, setting width:0, height: 0 by too zoomed in. - let preferredAspectRatio = state.preferredAspectRatio - state.proposedCrop?.updateCropExtentNormalizing( - resolvedRect, - respectingAspectRatio: preferredAspectRatio - ) - - } + record() /// Triggers layout update later debounce.on { [weak self] in @@ -736,14 +738,33 @@ extension CropView { } } - @inline(__always) - private func didChangeScrollView() { + private func record() { store.commit { state in - let rect = guideView.convert(guideView.bounds, to: imageView) - let resolvedRect = state.proposedCrop?.makeCropExtent(rect: rect) ?? .zero + let crop = state.proposedCrop! + + let rect = guideView.convert( + guideView.bounds, + to: imageView + ) + + let notRotatedRect = guideBackdropView.convert(guideBackdropView.frame, to: imageView) - // TODO: Might cause wrong cropping if set the invalid size or origin. For example, setting width:0, height: 0 by too zoomed in. + let resolvedRect = crop.makeCropExtent(rect: scrollView.croppingExtent(guideSize: guideView.bounds.size)) + + print( + """ + \(PixelAspectRatio(crop.cropExtent.size)), + \(PixelAspectRatio(notRotatedRect.size)), + \(PixelAspectRatio(rect.size)), + \(PixelAspectRatio(guideView.convert(guideView.bounds, to: scrollView).size)) + \(PixelAspectRatio(guideBackdropView.convert(guideBackdropView.bounds, to: scrollView).size)) + \(PixelAspectRatio(scrollView.croppingExtent(guideSize: guideView.bounds.size).size)) + + """ + ) + + // TODO: Might cause wrong cropping if set the invalid size or origin. For example, setting width:0, height: 0 by too zoomed in. let preferredAspectRatio = state.preferredAspectRatio state.proposedCrop?.updateCropExtentNormalizing( resolvedRect, @@ -752,6 +773,11 @@ extension CropView { } } + @inline(__always) + private func didChangeScrollView() { + record() + } + // MARK: UIScrollViewDelegate public func viewForZooming(in scrollView: UIScrollView) -> UIView? { @@ -853,14 +879,33 @@ extension CGRect { extension UIScrollView { - fileprivate func customZoom(to rect: CGRect, animated: Bool) { + fileprivate func croppingExtent(guideSize: CGSize) -> CGRect { + + // loss of precision + let r = CGRect( + origin: .init( + x: (contentOffset.x + contentInset.left) / self.zoomScale, + y: (contentOffset.y + contentInset.top) / self.zoomScale + ), + size: .init( + width: guideSize.width / self.zoomScale, + height: guideSize.height / self.zoomScale + ) + ) + + return r + } + + fileprivate func customZoom( + to rect: CGRect, + guideSize: CGSize, +// adjustmentRotation: CGFloat, + animated: Bool + ) { let contentSize = rect.size - let boundSize = CGSize( - width: self.bounds.width - (contentInset.left + contentInset.right), - height: self.bounds.height - (contentInset.top + contentInset.bottom) - ) + let boundSize = guideSize let minXScale = boundSize.width / contentSize.width let minYScale = boundSize.height / contentSize.height @@ -887,7 +932,18 @@ extension UIScrollView { setContentOffset(targetContentOffset, animated: false) } - print("[Zoom] targetScale: \(targetScale), targetContentOffset: \(targetContentOffset)") + #if DEBUG + croppingExtent(guideSize: guideSize) + + print(""" +[Zoom] +input: \(rect), +bound: \(boundSize), +targetScale: \(targetScale), +targetContentOffset: \(targetContentOffset) +""") + + #endif } }