From 1378512988894c267c0ae5293cff1110a015c920 Mon Sep 17 00:00:00 2001 From: Hiroshi Kimura Date: Mon, 12 Feb 2024 17:51:11 +0900 Subject: [PATCH] Support Any Rotation (#209) it's tough ![RPReplay_Final1707573417](https://github.com/FluidGroup/Brightroom/assets/1888355/d622cc12-0404-4a36-8e71-7379e42a5c3a) ## TODO - [x] Guide - aspect lock - [x] Guide - glitch while moving --- .gitmodules | 3 + Dev/Brightroom.xcodeproj/project.pbxproj | 75 +- .../xcshareddata/swiftpm/Package.resolved | 20 +- .../Contents/DemoCropMenuViewController.swift | 2 +- Dev/Sources/SwiftUIDemo/ContentView.swift | 478 +++++--- Dev/Sources/SwiftUIDemo/DemoCropView.swift | 170 ++- Dev/Sources/SwiftUIDemo/DemoMaskingView.swift | 27 + .../FullscreenIdentifiableView.swift | 5 +- .../SwiftUIDemo/GeometryPlayground.swift | 27 + .../SwiftUIDemo/IsolatedEditingView.swift | 2 +- .../SwiftUIDemo/Launch Screen.storyboard | 21 +- Dev/Sources/SwiftUIDemo/ObjectEdge.swift | 63 + .../SwiftUIDemo/RenderedResultView.swift | 53 + .../SwiftUIDemo/RotateScrollView.swift | 96 ++ .../BrightroomEngineTests/RendererTests.swift | 2 +- Sources/BrightroomEngine/Core/DrawnPath.swift | 7 +- .../BrightroomEngine/Core/EditingCrop.swift | 37 +- .../Engine/BrightRoomImageRenderer.swift | 119 +- .../Engine/CoreGraphics+.swift | 39 +- .../BrightroomEngine/Library/EngineLog.swift | 16 +- .../BrightroomEngine/Library/Geometry.swift | 15 +- .../ClassicImageEditOptions.swift | 16 +- .../ClassicImageEditViewController.swift | 18 +- .../PhotosCropAspectRatioControl.swift | 68 +- .../PhotosCrop/PhotosCropViewController.swift | 24 +- .../Crop/CropView.CropInsideOverlay.swift | 1 + .../Crop/CropView._CropScrollView.swift | 66 +- .../CropView._InteractiveCropGuideView.swift | 105 +- .../Shared/Components/Crop/CropView.swift | 1020 +++++++++++++---- .../Components/Crop/SwiftUICropView.swift | 54 +- .../Drawing/BlurryMaskingView.swift | 330 ++---- .../Components/Drawing/CanvasView.swift | 2 +- .../Shared/Utils/EditingCrop+.swift | 43 +- submodules/muukii/Reveal-SDK | 1 + 34 files changed, 2116 insertions(+), 909 deletions(-) create mode 100644 .gitmodules create mode 100644 Dev/Sources/SwiftUIDemo/DemoMaskingView.swift create mode 100644 Dev/Sources/SwiftUIDemo/GeometryPlayground.swift create mode 100644 Dev/Sources/SwiftUIDemo/ObjectEdge.swift create mode 100644 Dev/Sources/SwiftUIDemo/RenderedResultView.swift create mode 100644 Dev/Sources/SwiftUIDemo/RotateScrollView.swift create mode 160000 submodules/muukii/Reveal-SDK diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..9640ad6f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "submodules/muukii/Reveal-SDK"] + path = submodules/muukii/Reveal-SDK + url = https://github.com/muukii/Reveal-SDK.git diff --git a/Dev/Brightroom.xcodeproj/project.pbxproj b/Dev/Brightroom.xcodeproj/project.pbxproj index 10fa5c03..1ab16285 100644 --- a/Dev/Brightroom.xcodeproj/project.pbxproj +++ b/Dev/Brightroom.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 4B135F6E26136B5D003B5152 /* RendererOrientationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B135F6D26136B5D003B5152 /* RendererOrientationTests.swift */; }; + 4B2324E42B763D4A00662EB8 /* GeometryPlayground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2324E32B763D4A00662EB8 /* GeometryPlayground.swift */; }; 4B2A1107266B7B3000B0C885 /* test-image-10 in Resources */ = {isa = PBXBuildFile; fileRef = 4B2A10FA266B7B3000B0C885 /* test-image-10 */; }; 4B2A1108266B7B3000B0C885 /* test-image-2 in Resources */ = {isa = PBXBuildFile; fileRef = 4B2A10FB266B7B3000B0C885 /* test-image-2 */; }; 4B2A1109266B7B3000B0C885 /* test-image-4 in Resources */ = {isa = PBXBuildFile; fileRef = 4B2A10FC266B7B3000B0C885 /* test-image-4 */; }; @@ -30,6 +31,7 @@ 4B36195626107ADB00877B21 /* path-data in Resources */ = {isa = PBXBuildFile; fileRef = 4B36195526107ADB00877B21 /* path-data */; }; 4B4103582611EAA80061A218 /* ImitationTinderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4103572611EAA80061A218 /* ImitationTinderViewController.swift */; }; 4B4103602611EAB20061A218 /* DemoImitationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41035F2611EAB20061A218 /* DemoImitationsViewController.swift */; }; + 4B46E71B2B762E20003179C2 /* MondrianLayout in Frameworks */ = {isa = PBXBuildFile; productRef = 4B46E71A2B762E20003179C2 /* MondrianLayout */; }; 4B487E5728EEC0520026A8CF /* BrightroomEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 4B487E5628EEC0520026A8CF /* BrightroomEngine */; }; 4B487E5928EEC0520026A8CF /* BrightroomUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4B487E5828EEC0520026A8CF /* BrightroomUI */; }; 4B487E5F28EEC0670026A8CF /* BrightroomEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 4B487E5E28EEC0670026A8CF /* BrightroomEngine */; }; @@ -46,10 +48,13 @@ 4B58E830260F0027004A834F /* orientation_up_mirrored.HEIC in Resources */ = {isa = PBXBuildFile; fileRef = 4B254FEB260BB32600F77E9A /* orientation_up_mirrored.HEIC */; }; 4B58E831260F0027004A834F /* orientation_up.HEIC in Resources */ = {isa = PBXBuildFile; fileRef = 4B254FED260BB32600F77E9A /* orientation_up.HEIC */; }; 4B58E889260F0DEA004A834F /* DemoPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B58E888260F0DEA004A834F /* DemoPreviewViewController.swift */; }; + 4B5E72BC2B6B761800DE7A2A /* Reveal-SDK in Frameworks */ = {isa = PBXBuildFile; productRef = 4B5E72BB2B6B761800DE7A2A /* Reveal-SDK */; }; 4B600B23216B7C9C001E1456 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B600B22216B7C9C001E1456 /* AppDelegate.swift */; }; 4B600B2A216B7C9E001E1456 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B600B29216B7C9E001E1456 /* Assets.xcassets */; }; 4B600B2D216B7C9E001E1456 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4B600B2B216B7C9E001E1456 /* LaunchScreen.storyboard */; }; 4B75114025F13276002D804A /* DemoCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B75113F25F13276002D804A /* DemoCropView.swift */; }; + 4B7513C82B6BEDFD00A4743E /* SwiftUIHosting in Frameworks */ = {isa = PBXBuildFile; productRef = 4B7513C72B6BEDFD00A4743E /* SwiftUIHosting */; }; + 4B7513CB2B6BEE2100A4743E /* SwiftUISupport in Frameworks */ = {isa = PBXBuildFile; productRef = 4B7513CA2B6BEE2100A4743E /* SwiftUISupport */; }; 4B7EE66C25F515B400E17AF1 /* XCAssets+Generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7EE66B25F515B400E17AF1 /* XCAssets+Generated.swift */; }; 4B7EE66D25F515B400E17AF1 /* XCAssets+Generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7EE66B25F515B400E17AF1 /* XCAssets+Generated.swift */; }; 4B8B910726079256009BF084 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8B910626079256009BF084 /* Components.swift */; }; @@ -60,6 +65,7 @@ 4B8CAF6925FFCD720075032A /* IsolatedEditingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8CAF6825FFCD720075032A /* IsolatedEditingView.swift */; }; 4B8CAF6E25FFCE720075032A /* FullscreenIdentifiableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8CAF6D25FFCE720075032A /* FullscreenIdentifiableView.swift */; }; 4B8CAF7325FFD13D0075032A /* BlurryMaskingViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8CAF7225FFD13D0075032A /* BlurryMaskingViewWrapper.swift */; }; + 4B9325832B6A923400DDABAC /* RotateScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9325822B6A923400DDABAC /* RotateScrollView.swift */; }; 4B9369E825F940E600B18571 /* nasa.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 4B9369E725F940E600B18571 /* nasa.jpg */; }; 4B9369E925F940E600B18571 /* nasa.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 4B9369E725F940E600B18571 /* nasa.jpg */; }; 4B98CCBC25EFF31300E4F61F /* SwiftUIDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B98CCBB25EFF31300E4F61F /* SwiftUIDemoApp.swift */; }; @@ -85,6 +91,9 @@ 4BA41C94260CE817005E6FA7 /* orientation_down_mirrored.HEIC in Resources */ = {isa = PBXBuildFile; fileRef = 4B254FE9260BB32600F77E9A /* orientation_down_mirrored.HEIC */; }; 4BA41C99260CF035005E6FA7 /* DemoMaskingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA41C98260CF035005E6FA7 /* DemoMaskingViewController.swift */; }; 4BA41C9E260CF04D005E6FA7 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA41C9D260CF04D005E6FA7 /* Utilities.swift */; }; + 4BA9896F2B70BF2000EB7983 /* RenderedResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA9896E2B70BF2000EB7983 /* RenderedResultView.swift */; }; + 4BA989712B70F10300EB7983 /* ObjectEdge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA989702B70F10300EB7983 /* ObjectEdge.swift */; }; + 4BA989732B7122C800EB7983 /* DemoMaskingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA989722B7122C800EB7983 /* DemoMaskingView.swift */; }; 4BB01A1726139653002C831E /* DemoMTLTextureViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB01A1626139653002C831E /* DemoMTLTextureViewController.swift */; }; 4BB4B105260F8589007617D7 /* LUT_64_Neutral.png in Resources */ = {isa = PBXBuildFile; fileRef = 4BB4B104260F8589007617D7 /* LUT_64_Neutral.png */; }; 4BB7B9C9277A439C0014B62A /* LUT_64_Dark_HighContrast_v3.png in Resources */ = {isa = PBXBuildFile; fileRef = 4BB7B9C8277A439B0014B62A /* LUT_64_Dark_HighContrast_v3.png */; }; @@ -559,6 +568,7 @@ /* Begin PBXFileReference section */ 4B135F6D26136B5D003B5152 /* RendererOrientationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererOrientationTests.swift; sourceTree = ""; }; + 4B2324E32B763D4A00662EB8 /* GeometryPlayground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeometryPlayground.swift; sourceTree = ""; }; 4B254FE6260BB32600F77E9A /* orientation_left_mirrored.HEIC */ = {isa = PBXFileReference; lastKnownFileType = file; path = orientation_left_mirrored.HEIC; sourceTree = ""; }; 4B254FE7260BB32600F77E9A /* orientation_right_mirrored.HEIC */ = {isa = PBXFileReference; lastKnownFileType = file; path = orientation_right_mirrored.HEIC; sourceTree = ""; }; 4B254FE8260BB32600F77E9A /* orientation_left.HEIC */ = {isa = PBXFileReference; lastKnownFileType = file; path = orientation_left.HEIC; sourceTree = ""; }; @@ -589,6 +599,7 @@ 4B41035F2611EAB20061A218 /* DemoImitationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoImitationsViewController.swift; sourceTree = ""; }; 4B524FE229B8F6A600C1A416 /* Brightroom */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Brightroom; path = ..; sourceTree = ""; }; 4B58E888260F0DEA004A834F /* DemoPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoPreviewViewController.swift; sourceTree = ""; }; + 4B5E72BA2B6B760D00DE7A2A /* Reveal-SDK */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "Reveal-SDK"; path = "../submodules/muukii/Reveal-SDK"; sourceTree = ""; }; 4B600B20216B7C9C001E1456 /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4B600B22216B7C9C001E1456 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 4B600B29216B7C9E001E1456 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -607,6 +618,7 @@ 4B8CAF6825FFCD720075032A /* IsolatedEditingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsolatedEditingView.swift; sourceTree = ""; }; 4B8CAF6D25FFCE720075032A /* FullscreenIdentifiableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenIdentifiableView.swift; sourceTree = ""; }; 4B8CAF7225FFD13D0075032A /* BlurryMaskingViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurryMaskingViewWrapper.swift; sourceTree = ""; }; + 4B9325822B6A923400DDABAC /* RotateScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RotateScrollView.swift; sourceTree = ""; }; 4B9369E725F940E600B18571 /* nasa.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = nasa.jpg; sourceTree = ""; }; 4B98CCB925EFF31300E4F61F /* SwiftUIDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUIDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4B98CCBB25EFF31300E4F61F /* SwiftUIDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIDemoApp.swift; sourceTree = ""; }; @@ -616,6 +628,9 @@ 4B98CCE625EFF4EF00E4F61F /* Mocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mocks.swift; sourceTree = ""; }; 4BA41C98260CF035005E6FA7 /* DemoMaskingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoMaskingViewController.swift; sourceTree = ""; }; 4BA41C9D260CF04D005E6FA7 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; + 4BA9896E2B70BF2000EB7983 /* RenderedResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderedResultView.swift; sourceTree = ""; }; + 4BA989702B70F10300EB7983 /* ObjectEdge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectEdge.swift; sourceTree = ""; }; + 4BA989722B7122C800EB7983 /* DemoMaskingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoMaskingView.swift; sourceTree = ""; }; 4BB01A1626139653002C831E /* DemoMTLTextureViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoMTLTextureViewController.swift; sourceTree = ""; }; 4BB4B104260F8589007617D7 /* LUT_64_Neutral.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = LUT_64_Neutral.png; path = Resources/LUT_64_Neutral.png; sourceTree = SOURCE_ROOT; }; 4BB7B9C8277A439B0014B62A /* LUT_64_Dark_HighContrast_v3.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LUT_64_Dark_HighContrast_v3.png; sourceTree = ""; }; @@ -1043,7 +1058,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4B46E71B2B762E20003179C2 /* MondrianLayout in Frameworks */, + 4B7513CB2B6BEE2100A4743E /* SwiftUISupport in Frameworks */, FC06C87F2A53AF4500215B89 /* BrightroomEngine in Frameworks */, + 4B7513C82B6BEDFD00A4743E /* SwiftUIHosting in Frameworks */, + 4B5E72BC2B6B761800DE7A2A /* Reveal-SDK in Frameworks */, FC06C8812A53AF4500215B89 /* BrightroomUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1157,6 +1176,7 @@ 4B600AF3216B7A94001E1456 = { isa = PBXGroup; children = ( + 4B5E72BA2B6B760D00DE7A2A /* Reveal-SDK */, 4B524FE129B8F6A600C1A416 /* Packages */, 4BE9B3D1260BA72D000A3D09 /* Bundle */, 4B6A5F5B2179D43A004D68DC /* Sources */, @@ -1235,6 +1255,11 @@ 4B98CCC425EFF31400E4F61F /* Info.plist */, 4B98CCC125EFF31400E4F61F /* Preview Content */, 4BB9180B25F5460000C446B8 /* Launch Screen.storyboard */, + 4B9325822B6A923400DDABAC /* RotateScrollView.swift */, + 4BA9896E2B70BF2000EB7983 /* RenderedResultView.swift */, + 4BA989702B70F10300EB7983 /* ObjectEdge.swift */, + 4BA989722B7122C800EB7983 /* DemoMaskingView.swift */, + 4B2324E32B763D4A00662EB8 /* GeometryPlayground.swift */, ); path = SwiftUIDemo; sourceTree = ""; @@ -1755,6 +1780,10 @@ packageProductDependencies = ( FC06C87E2A53AF4500215B89 /* BrightroomEngine */, FC06C8802A53AF4500215B89 /* BrightroomUI */, + 4B5E72BB2B6B761800DE7A2A /* Reveal-SDK */, + 4B7513C72B6BEDFD00A4743E /* SwiftUIHosting */, + 4B7513CA2B6BEE2100A4743E /* SwiftUISupport */, + 4B46E71A2B762E20003179C2 /* MondrianLayout */, ); productName = SwiftUIDemo; productReference = 4B98CCB925EFF31300E4F61F /* SwiftUIDemo.app */; @@ -1857,6 +1886,8 @@ 4B487E6A28EECC480026A8CF /* XCRemoteSwiftPackageReference "GlossButtonNode" */, 4B487E6D28EECC760026A8CF /* XCRemoteSwiftPackageReference "MondrianLayout" */, 4B487E7028EECCB10026A8CF /* XCRemoteSwiftPackageReference "TextureSwiftSupport" */, + 4B7513C62B6BEDFC00A4743E /* XCRemoteSwiftPackageReference "swiftui-hosting" */, + 4B7513C92B6BEE2100A4743E /* XCRemoteSwiftPackageReference "swiftui-support" */, ); productRefGroup = 4B600AFE216B7A94001E1456 /* Products */; projectDirPath = ""; @@ -2387,11 +2418,16 @@ buildActionMask = 2147483647; files = ( 4B8CAF6E25FFCE720075032A /* FullscreenIdentifiableView.swift in Sources */, + 4B2324E42B763D4A00662EB8 /* GeometryPlayground.swift in Sources */, 4B8CAF7325FFD13D0075032A /* BlurryMaskingViewWrapper.swift in Sources */, + 4BA989732B7122C800EB7983 /* DemoMaskingView.swift in Sources */, 4B8CAF6925FFCD720075032A /* IsolatedEditingView.swift in Sources */, + 4BA989712B70F10300EB7983 /* ObjectEdge.swift in Sources */, 4B7EE66D25F515B400E17AF1 /* XCAssets+Generated.swift in Sources */, + 4BA9896F2B70BF2000EB7983 /* RenderedResultView.swift in Sources */, 4B98CCBE25EFF31300E4F61F /* ContentView.swift in Sources */, 4B75114025F13276002D804A /* DemoCropView.swift in Sources */, + 4B9325832B6A923400DDABAC /* RotateScrollView.swift in Sources */, 4B98CCBC25EFF31300E4F61F /* SwiftUIDemoApp.swift in Sources */, 4B98CCE725EFF4EF00E4F61F /* Mocks.swift in Sources */, ); @@ -2643,7 +2679,7 @@ DEVELOPMENT_TEAM = JX92XL88RZ; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Sources/SwiftUIDemo/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2665,7 +2701,7 @@ DEVELOPMENT_TEAM = JX92XL88RZ; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Sources/SwiftUIDemo/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2884,6 +2920,22 @@ kind = branch; }; }; + 4B7513C62B6BEDFC00A4743E /* XCRemoteSwiftPackageReference "swiftui-hosting" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/FluidGroup/swiftui-hosting.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.0; + }; + }; + 4B7513C92B6BEE2100A4743E /* XCRemoteSwiftPackageReference "swiftui-support" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/FluidGroup/swiftui-support.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.5.0; + }; + }; A6ABB4FA260AA8FB006B46ED /* XCRemoteSwiftPackageReference "TransitionPatch" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/muukii/TransitionPatch.git"; @@ -2903,6 +2955,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 4B46E71A2B762E20003179C2 /* MondrianLayout */ = { + isa = XCSwiftPackageProductDependency; + package = 4B487E6D28EECC760026A8CF /* XCRemoteSwiftPackageReference "MondrianLayout" */; + productName = MondrianLayout; + }; 4B487E5628EEC0520026A8CF /* BrightroomEngine */ = { isa = XCSwiftPackageProductDependency; productName = BrightroomEngine; @@ -2935,6 +2992,20 @@ package = 4B487E7028EECCB10026A8CF /* XCRemoteSwiftPackageReference "TextureSwiftSupport" */; productName = TextureSwiftSupport; }; + 4B5E72BB2B6B761800DE7A2A /* Reveal-SDK */ = { + isa = XCSwiftPackageProductDependency; + productName = "Reveal-SDK"; + }; + 4B7513C72B6BEDFD00A4743E /* SwiftUIHosting */ = { + isa = XCSwiftPackageProductDependency; + package = 4B7513C62B6BEDFC00A4743E /* XCRemoteSwiftPackageReference "swiftui-hosting" */; + productName = SwiftUIHosting; + }; + 4B7513CA2B6BEE2100A4743E /* SwiftUISupport */ = { + isa = XCSwiftPackageProductDependency; + package = 4B7513C92B6BEE2100A4743E /* XCRemoteSwiftPackageReference "swiftui-support" */; + productName = SwiftUISupport; + }; FC06C87E2A53AF4500215B89 /* BrightroomEngine */ = { isa = XCSwiftPackageProductDependency; productName = BrightroomEngine; diff --git a/Dev/Brightroom.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Dev/Brightroom.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cc2474bc..04e14419 100644 --- a/Dev/Brightroom.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Dev/Brightroom.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -81,6 +81,24 @@ "version" : "509.0.2" } }, + { + "identity" : "swiftui-hosting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/FluidGroup/swiftui-hosting.git", + "state" : { + "revision" : "7e8eaca72eae910d6d3b6272c263c6c3a10b755c", + "version" : "1.2.0" + } + }, + { + "identity" : "swiftui-support", + "kind" : "remoteSourceControl", + "location" : "https://github.com/FluidGroup/swiftui-support.git", + "state" : { + "revision" : "c610c1e46c14c4660beb4ef7fe0241941dbecdc6", + "version" : "0.5.0" + } + }, { "identity" : "texture", "kind" : "remoteSourceControl", @@ -96,7 +114,7 @@ "location" : "https://github.com/FluidGroup/TextureSwiftSupport", "state" : { "branch" : "main", - "revision" : "5de10d4c9eae028a8c560ba1c964f76bc8b731c5" + "revision" : "5bae50cab3798dccb8b98c3ffbc70320ae66b45a" } }, { diff --git a/Dev/Sources/Demo/Contents/DemoCropMenuViewController.swift b/Dev/Sources/Demo/Contents/DemoCropMenuViewController.swift index afa88fc6..db70da27 100644 --- a/Dev/Sources/Demo/Contents/DemoCropMenuViewController.swift +++ b/Dev/Sources/Demo/Contents/DemoCropMenuViewController.swift @@ -213,7 +213,7 @@ final class DemoCropMenuViewController: StackScrollNodeViewController { cropModifier: .init { image, crop, completion in var new = crop - new.updateCropExtentNormalizing( + new.updateCropExtent( CGRect( origin: .zero, size: .init(width: 100, height: 300) diff --git a/Dev/Sources/SwiftUIDemo/ContentView.swift b/Dev/Sources/SwiftUIDemo/ContentView.swift index 79feb40a..7b231e07 100644 --- a/Dev/Sources/SwiftUIDemo/ContentView.swift +++ b/Dev/Sources/SwiftUIDemo/ContentView.swift @@ -1,200 +1,216 @@ import BrightroomEngine import BrightroomUI +import PhotosUI import SwiftUI struct ContentView: View { - @State private var image: SwiftUI.Image? - @State private var sharedStack = Mocks.makeEditingStack(image: Mocks.imageHorizontal()) @State private var fullScreenView: FullscreenIdentifiableView? - @State private var stackForHorizontal: EditingStack = Mocks.makeEditingStack( - image: Asset.horizontalRect.image - ) - @State private var stackForVertical: EditingStack = Mocks.makeEditingStack( - image: Asset.verticalRect.image - ) - @State private var stackForSquare: EditingStack = Mocks.makeEditingStack( - image: Asset.squareRect.image - ) - @State private var stackForNasa: EditingStack = Mocks.makeEditingStack( - fileURL: - Bundle.main.path( - forResource: "nasa", - ofType: "jpg" - ).map { - URL(fileURLWithPath: $0) - }! - ) - @State private var stackForSmall: EditingStack = Mocks.makeEditingStack( - image: Asset.superSmall.image - ) + @State var stack = Mocks.makeEditingStack(image: Mocks.imageHorizontal()) var body: some View { NavigationView { VStack { - Group { - if let image = image { - image - .resizable() - .aspectRatio(contentMode: .fit) - } else { - Color.gray - } - } - .frame(width: 120, height: 120, alignment: .center) Form { NavigationLink("Isolated", destination: IsolatedEditinView()) - Button("Component: Crop") { - fullScreenView = .init { DemoCropView(editingStack: sharedStack) } + if #available(iOS 16, *) { + NavigationLink("Pick image") { + WorkingOnPicked() + } } - Section(content: { - Button("Crop: Horizontal") { + Section("Restoration") { + Button("Crop") { fullScreenView = .init { - SwiftUIPhotosCropView( - editingStack: stackForHorizontal, - onDone: { - self.image = try! stackForHorizontal.makeRenderer().render().swiftUIImage - self.fullScreenView = nil - }, - onCancel: {} - ) + DemoCropView(editingStack: { stack }) } } - Button("Crop: Vertical") { + Button("Masking") { fullScreenView = .init { - SwiftUIPhotosCropView( - editingStack: stackForVertical, - onDone: { - self.image = try! stackForVertical.makeRenderer().render().swiftUIImage - self.fullScreenView = nil - }, - onCancel: {} - ) + DemoMaskingView { + stack + } } } + } + + Section("Crop") { - Button("Crop: Square") { + Button("Local") { fullScreenView = .init { - SwiftUIPhotosCropView( - editingStack: stackForSquare, - onDone: { - self.image = try! stackForSquare.makeRenderer().render().swiftUIImage - self.fullScreenView = nil - }, - onCancel: {} + DemoCropView( + editingStack: { Mocks.makeEditingStack(image: Mocks.imageHorizontal()) } ) } } + } - Button("Crop: Nasa") { + Section("Blur Masking") { + Button("Local") { fullScreenView = .init { - SwiftUIPhotosCropView( - editingStack: stackForNasa, - onDone: { - self.image = try! stackForNasa.makeRenderer().render().swiftUIImage - self.fullScreenView = nil - }, - onCancel: {} - ) + DemoMaskingView { + Mocks.makeEditingStack( + image: Asset.horizontalRect.image + ) + } } } - Button("Crop: Super small") { + Button("Remote") { fullScreenView = .init { - SwiftUIPhotosCropView( - editingStack: stackForSmall, - onDone: { - self.image = try! stackForSmall.makeRenderer().render().swiftUIImage - self.fullScreenView = nil - }, - onCancel: {} - ) + DemoMaskingView { + EditingStack( + imageProvider: .init( + editableRemoteURL: URL( + string: + "https://images.unsplash.com/photo-1604456930969-37f67bcd6e1e?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1" + )! + ) + ) + } } } + } - Button("Crop: Remote") { - let stack = EditingStack( - imageProvider: .init( - editableRemoteURL: URL( - string: - "https://images.unsplash.com/photo-1604456930969-37f67bcd6e1e?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1" - )! - ) - ) + Section( + "PhotosCrop", + content: { + Button("Horizontal") { + fullScreenView = .init { + DemoPhotosCropView(stack: { + Mocks.makeEditingStack( + image: Asset.horizontalRect.image + ) + }) + } + } - fullScreenView = .init { - SwiftUIPhotosCropView( - editingStack: stack, - onDone: { - self.image = try! stack.makeRenderer().render().swiftUIImage - self.fullScreenView = nil - }, - onCancel: {} - ) + Button("Vertical") { + fullScreenView = .init { + DemoPhotosCropView(stack: { + Mocks.makeEditingStack( + image: Asset.verticalRect.image + ) + }) + } } - } - Button("Crop: Remote - preview") { - let stack = EditingStack( - imageProvider: .init( - editableRemoteURL: URL( - string: - "https://images.unsplash.com/photo-1597522781074-9a05ab90638e?ixlib=rb-1.2.1&ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D" - )! - ) - ) + Button("Square") { + fullScreenView = .init { + DemoPhotosCropView(stack: { + Mocks.makeEditingStack( + image: Asset.squareRect.image + ) + }) + } + } - fullScreenView = .init { - SwiftUIPhotosCropView( - editingStack: stack, - onDone: { - self.image = try! stack.makeRenderer().render().swiftUIImage - self.fullScreenView = nil - }, - onCancel: {} - ) + Button("Nasa") { + fullScreenView = .init { + DemoPhotosCropView(stack: { + Mocks.makeEditingStack( + fileURL: + Bundle.main.path( + forResource: "nasa", + ofType: "jpg" + ).map { + URL(fileURLWithPath: $0) + }! + ) + }) + } + } + + Button("Super small") { + fullScreenView = .init { + DemoPhotosCropView(stack: { + Mocks.makeEditingStack( + image: Asset.superSmall.image + ) + }) + } + } + + Button("Remote") { + + fullScreenView = .init { + + DemoPhotosCropView(stack: { + EditingStack( + imageProvider: .init( + editableRemoteURL: URL( + string: + "https://images.unsplash.com/photo-1604456930969-37f67bcd6e1e?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1" + )! + ) + ) + }) + + } + } + + Button("Remote - preview") { + + fullScreenView = .init { + + DemoPhotosCropView(stack: { + EditingStack( + imageProvider: .init( + editableRemoteURL: URL( + string: + "https://images.unsplash.com/photo-1597522781074-9a05ab90638e?ixlib=rb-1.2.1&ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D" + )! + ) + ) + }) + + } } } - }) + ) Section(content: { Button("PixelEditor Square") { - let stack = EditingStack.init( - imageProvider: .init(image: Asset.l1000316.image), - cropModifier: .init { _, crop, completion in - var new = crop - new.updateCropExtent(toFitAspectRatio: .square) - completion(new) - } - ) fullScreenView = .init { - PixelEditWrapper(editingStack: stack) { - self.image = try! stackForHorizontal.makeRenderer().render().swiftUIImage - self.fullScreenView = nil - } + DemoPixelEditor(editingStack: { + EditingStack.init( + imageProvider: .init(image: Asset.l1000316.image), + cropModifier: .init { _, crop, completion in + var new = crop + new.updateCropExtent(toFitAspectRatio: .square) + completion(new) + } + ) + }) } } Button("PixelEditor") { - let stack = EditingStack.init( - imageProvider: .init(image: Asset.l1000316.image) - ) fullScreenView = .init { - PixelEditWrapper(editingStack: stack) { - self.image = try! stackForHorizontal.makeRenderer().render().swiftUIImage - self.fullScreenView = nil - } + DemoPixelEditor( + editingStack: { + EditingStack.init( + imageProvider: .init(image: Asset.l1000316.image) + ) + }, + options: .init(croppingAspectRatio: nil) + ) } } + }) + + Section("Lab") { + NavigationLink("Rotating", destination: BookRotateScrollView()) + } } + } - .navigationTitle("Pixel") + .navigationTitle("Brightroom") .fullScreenCover( item: $fullScreenView, onDismiss: {}, @@ -204,25 +220,181 @@ struct ContentView: View { ) } .onAppear(perform: { - ColorCubeStorage.loadToDefault() + try? PresetStorage.default.loadLUTs() + }) + } +} + +@available(iOS 16, *) +struct WorkingOnPicked: View { + + @State private var item: PhotosPickerItem? + @State private var editingStack: EditingStack? + @State private var fullScreenView: FullscreenIdentifiableView? + + var body: some View { + + Form { + PhotosPicker("Select", selection: $item) + + if let stack = editingStack { + Section("Components") { + + Button("Crop") { + fullScreenView = .init { + DemoCropView(editingStack: { stack }) + } + } + + Button("Masking") { + fullScreenView = .init { + DemoMaskingView { + stack + } + } + } + } + + Section("BuiltIn") { + Button("PhotosCrop") { + fullScreenView = .init { + DemoPhotosCropView(stack: { + Mocks.makeEditingStack( + image: Asset.horizontalRect.image + ) + }) + } + } + + Button("ClassicEditor") { + fullScreenView = .init { + DemoPixelEditor(editingStack: { + stack + }) + } + } + } + + } + + } + .fullScreenCover( + item: $fullScreenView, + onDismiss: {}, + content: { + $0 + } + ) + .onChange(of: item, perform: { value in + guard let value else { return } + + Task { + + do { + guard let transferable = try await value.loadTransferable(type: Data.self) else { + print("Error: no transferable found.") + return + } + + let stack = EditingStack(imageProvider: try .init(data: transferable)) + + self.editingStack = stack + + } catch { + print("Error: \(error)") + } + + } }) + + } + +} + +struct DemoPhotosCropView: View { + + @ObjectEdge var stack: EditingStack + + @State var resultImage: ResultImage? + + init(stack: @escaping () -> EditingStack) { + self._stack = .init(wrappedValue: stack()) + } + + var body: some View { + + SwiftUIPhotosCropView( + editingStack: stack, + onDone: { + let image = try! stack.makeRenderer().render().cgImage + self.resultImage = .init(cgImage: image) + }, + onCancel: {} + ) + .sheet(item: $resultImage) { + RenderedResultView(result: $0) + } + + } + +} + +struct DemoPixelEditor: View { + + @ObjectEdge var editingStack: EditingStack + @State var resultImage: ResultImage? + + let options: ClassicImageEditOptions + + init( + editingStack: @escaping () -> EditingStack, + options: ClassicImageEditOptions = .init() + ) { + self._editingStack = .init(wrappedValue: editingStack()) + self.options = options + } + + var body: some View { + DemoPixelEditWrapper( + editingStack: editingStack, + options: options, + + onCompleted: { + let image = try! editingStack.makeRenderer().render().cgImage + self.resultImage = .init(cgImage: image) + } + ) + .sheet(item: $resultImage) { + RenderedResultView(result: $0) + } } } -struct PixelEditWrapper: UIViewControllerRepresentable { +struct DemoPixelEditWrapper: UIViewControllerRepresentable { + typealias UIViewControllerType = UINavigationController - private let editingStack: EditingStack private let onCompleted: () -> Void - init(editingStack: EditingStack, onCompleted: @escaping () -> Void) { + let editingStack: EditingStack + let options: ClassicImageEditOptions + + init( + editingStack: EditingStack, + options: ClassicImageEditOptions, + onCompleted: @escaping () -> Void + ) { self.editingStack = editingStack self.onCompleted = onCompleted - editingStack.start() + self.options = options } func makeUIViewController(context: Context) -> UINavigationController { - let cropViewController = ClassicImageEditViewController(editingStack: editingStack) + editingStack.start() + let cropViewController = ClassicImageEditViewController( + editingStack: editingStack, + options: options + ) cropViewController.handlers.didEndEditing = { _, _ in onCompleted() } @@ -232,20 +404,6 @@ struct PixelEditWrapper: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} } -var _loaded = false -extension ColorCubeStorage { - static func loadToDefault() { - guard _loaded == false else { - return - } - _loaded = true - - do { - let loader = ColorCubeLoader(bundle: .main) - self.default.filters = try loader.load() - - } catch { - assertionFailure("\(error)") - } - } +#Preview { + ContentView() } diff --git a/Dev/Sources/SwiftUIDemo/DemoCropView.swift b/Dev/Sources/SwiftUIDemo/DemoCropView.swift index 1332855a..7da328d9 100644 --- a/Dev/Sources/SwiftUIDemo/DemoCropView.swift +++ b/Dev/Sources/SwiftUIDemo/DemoCropView.swift @@ -6,41 +6,177 @@ // Copyright © 2021 muukii. All rights reserved. // -import BrightroomUI import BrightroomEngine +import BrightroomUI import SwiftUI import UIKit struct DemoCropView: View { - let editingStack: EditingStack + + @ObjectEdge var editingStack: EditingStack + + @State var rotation: EditingCrop.Rotation? + @State var adjustmentAngle: EditingCrop.AdjustmentAngle? + @State var croppingAspectRatio: PixelAspectRatio? + + @State var resultImage: ResultImage? + + init( + editingStack: @escaping () -> EditingStack + ) { + self._editingStack = .init(wrappedValue: editingStack()) + } var body: some View { VStack { ZStack { Color.black .ignoresSafeArea() - - SwiftUICropView( - editingStack: editingStack, - cropInsideOverlay: .init( - /** - Here is how to create a customized overlay view. - */ - VStack { - Circle() - .foregroundColor(.white) - .frame(width: 50, height: 50, alignment: .center) + + VStack { + + HStack { + Button(action: { + + if self.rotation == nil { + self.rotation = .angle_0 + self.rotation = self.rotation!.next() + } else { + self.rotation = self.rotation!.next() + } + + }, label: { + Image(systemName: "rotate.left") }) - ) - .clipped() + + Button(action: {}, label: { + Image(systemName: "aspectratio") + }) + } + + SwiftUICropView( + editingStack: editingStack + ) + .rotation(rotation) + .adjustmentAngle(adjustmentAngle) + .croppingAspectRatio(croppingAspectRatio) + .clipped() + + VStack { + + HStack(spacing: 18) { + + Button { + + } label: { + ZStack { + RoundedRectangle(cornerRadius: 2) + .stroke(style: .init(lineWidth: 5)) + .fill(Color(white: 0.5, opacity: 1)) + + RoundedRectangle(cornerRadius: 2) + .fill(Color(white: 0.3, opacity: 1)) + + Image(systemName: "checkmark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + .foregroundStyle(Color.black) + } + .tint(.white) + .frame(width: 18, height: 28) + } + + Button { + + } label: { + ZStack { + RoundedRectangle(cornerRadius: 2) + .stroke(style: .init(lineWidth: 5)) + .fill(Color(white: 0.5, opacity: 1)) + + RoundedRectangle(cornerRadius: 2) + .fill(Color(white: 0.3, opacity: 1)) + + Image(systemName: "checkmark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + .foregroundStyle(Color.black) + } + .tint(.white) + .frame(width: 28, height: 18) + } + } + + HStack { + Button("16:9") { + self.croppingAspectRatio = .init(width: 16, height: 9) + } + + Button("Freeform") { + self.croppingAspectRatio = nil + } + } + HStack { + Button("0") { + self.rotation = .angle_0 + } + Button("90") { + self.rotation = .angle_90 + } + Button("180") { + self.rotation = .angle_180 + } + Button("270") { + self.rotation = .angle_270 + } + Button("- 10") { + if self.adjustmentAngle == nil { + self.adjustmentAngle = .zero + } + self.adjustmentAngle! -= .degrees(10) + } + Button("+ 10") { + if self.adjustmentAngle == nil { + self.adjustmentAngle = .zero + } + self.adjustmentAngle! += .degrees(10) + } + } + } + } } Button("Done") { - let image = try! editingStack.makeRenderer().render().swiftUIImage - print(image) + let image = try! editingStack.makeRenderer().render().cgImage + self.resultImage = .init(cgImage: image) } } .onAppear { editingStack.start() } + .sheet(item: $resultImage) { + RenderedResultView(result: $0) + } } } + +#Preview { + DemoCropView( + editingStack: { Mocks.makeEditingStack(image: Mocks.imageHorizontal()) } + ) +} + +#Preview { + Text("") + .onAppear { + + let uiView = UIView(frame: .init(origin: .zero, size: .init(width: 100, height: 200))) + print(uiView.frame) + + uiView.transform = .init(rotationAngle: Angle(degrees: 10).radians) + print(uiView.transform) + + print(uiView.frame, uiView.bounds) + } +} diff --git a/Dev/Sources/SwiftUIDemo/DemoMaskingView.swift b/Dev/Sources/SwiftUIDemo/DemoMaskingView.swift new file mode 100644 index 00000000..66ed9a6b --- /dev/null +++ b/Dev/Sources/SwiftUIDemo/DemoMaskingView.swift @@ -0,0 +1,27 @@ +import BrightroomEngine +import BrightroomUI +import SwiftUI + +struct DemoMaskingView: View { + + @ObjectEdge var editingStack: EditingStack + + init(editingStack: @escaping () -> EditingStack) { + self._editingStack = .init(wrappedValue: editingStack()) + } + + var body: some View { + SwiftUIBlurryMaskingView(editingStack: editingStack) + } + +} + +#Preview { + DemoMaskingView( + editingStack: { + Mocks.makeEditingStack( + image: Asset.verticalRect.image + ) + } + ) +} diff --git a/Dev/Sources/SwiftUIDemo/FullscreenIdentifiableView.swift b/Dev/Sources/SwiftUIDemo/FullscreenIdentifiableView.swift index 3572a4e9..466a0848 100644 --- a/Dev/Sources/SwiftUIDemo/FullscreenIdentifiableView.swift +++ b/Dev/Sources/SwiftUIDemo/FullscreenIdentifiableView.swift @@ -9,8 +9,9 @@ import SwiftUI struct FullscreenIdentifiableView: View, Identifiable { - @Environment(\.presentationMode) var presentationMode + @Environment(\.dismiss) var dismiss + let id = UUID() private let content: AnyView @@ -22,7 +23,7 @@ struct FullscreenIdentifiableView: View, Identifiable { VStack { content Button("Dismiss") { - presentationMode.wrappedValue.dismiss() + dismiss() } .padding(16) } diff --git a/Dev/Sources/SwiftUIDemo/GeometryPlayground.swift b/Dev/Sources/SwiftUIDemo/GeometryPlayground.swift new file mode 100644 index 00000000..faa9892f --- /dev/null +++ b/Dev/Sources/SwiftUIDemo/GeometryPlayground.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct BookGeometryPlaygroud: View, PreviewProvider { + var body: some View { + ContentView() + } + + static var previews: some View { + Self() + } + + private struct ContentView: View { + + var body: some View { + ZStack { + + Color.black.opacity(0.2) + .frame(width: 200, height: 300) + + Color.black.opacity(0.2) + .frame(width: 200, height: 300) + .rotationEffect(.degrees(10)) + + } + } + } +} diff --git a/Dev/Sources/SwiftUIDemo/IsolatedEditingView.swift b/Dev/Sources/SwiftUIDemo/IsolatedEditingView.swift index 0017fa80..a0da9e49 100644 --- a/Dev/Sources/SwiftUIDemo/IsolatedEditingView.swift +++ b/Dev/Sources/SwiftUIDemo/IsolatedEditingView.swift @@ -20,7 +20,7 @@ struct IsolatedEditinView: View { } Button("Custom Crop") { - fullScreenView = .init { DemoCropView(editingStack: editingStack) } + fullScreenView = .init { DemoCropView(editingStack: {editingStack}) } } Button("Blur Mask") { diff --git a/Dev/Sources/SwiftUIDemo/Launch Screen.storyboard b/Dev/Sources/SwiftUIDemo/Launch Screen.storyboard index 3669a276..6155e3c3 100644 --- a/Dev/Sources/SwiftUIDemo/Launch Screen.storyboard +++ b/Dev/Sources/SwiftUIDemo/Launch Screen.storyboard @@ -1,8 +1,9 @@ - - + + + - - + + @@ -12,22 +13,23 @@ - + - + @@ -35,9 +37,8 @@ - + - diff --git a/Dev/Sources/SwiftUIDemo/ObjectEdge.swift b/Dev/Sources/SwiftUIDemo/ObjectEdge.swift new file mode 100644 index 00000000..e6d095b2 --- /dev/null +++ b/Dev/Sources/SwiftUIDemo/ObjectEdge.swift @@ -0,0 +1,63 @@ +import SwiftUI + +@propertyWrapper +struct ObjectEdge: DynamicProperty { + + @State private var box: Box = .init() + + var wrappedValue: O { + if let value = box.value { + return value + } else { + box.value = factory() + return box.value! + } + } + + private let factory: () -> O + + init(wrappedValue factory: @escaping @autoclosure () -> O) { + self.factory = factory + } + + private final class Box { + var value: Value? + } + +} + +#if DEBUG + +@available(iOS 17, *) +@Observable +private final class Model { + + var count: Int = 0 + + func up() { + count += 1 + } +} + +@available(iOS 17, *) +private struct Demo: View { + + @ObjectEdge var model: Model = .init() + + var body: some View { + + VStack { + Text("\(model.count)") + Button("Up") { + model.up() + } + } + } +} + +@available(iOS 17, *) +#Preview { + Demo() +} + +#endif diff --git a/Dev/Sources/SwiftUIDemo/RenderedResultView.swift b/Dev/Sources/SwiftUIDemo/RenderedResultView.swift new file mode 100644 index 00000000..788e1e94 --- /dev/null +++ b/Dev/Sources/SwiftUIDemo/RenderedResultView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct ResultImage: Identifiable { + let id: String + let cgImage: CGImage + let image: Image + + init(cgImage: CGImage) { + self.id = UUID().uuidString + self.cgImage = cgImage + self.image = .init(decorative: cgImage, scale: 1, orientation: .up) + } +} + +struct RenderedResultView: View { + + let result: ResultImage + + var body: some View { + VStack { + result.image + .resizable() + .aspectRatio(contentMode: .fit) + .padding() + + Text(Self.makeMetadataString(image: result.cgImage)) + .foregroundStyle(.secondary) + .font(.caption) + } + } + + static func makeMetadataString(image: CGImage) -> String { + + // let formatter = ByteCountFormatter() + // formatter.countStyle = .file + // + // let jpegSize = formatter.string( + // fromByteCount: Int64(image.jpegData(compressionQuality: 1)!.count) + // ) + // + let cgImage = image + + let meta = """ + size: \(image.width), \(cgImage.height) + colorSpace: \(cgImage.colorSpace.map { String(describing: $0) } ?? "null") + bit-depth: \(cgImage.bitsPerPixel / 4) + bytesPerRow: \(cgImage.bytesPerRow) + """ + + return meta + } + +} diff --git a/Dev/Sources/SwiftUIDemo/RotateScrollView.swift b/Dev/Sources/SwiftUIDemo/RotateScrollView.swift new file mode 100644 index 00000000..e954a613 --- /dev/null +++ b/Dev/Sources/SwiftUIDemo/RotateScrollView.swift @@ -0,0 +1,96 @@ +import SwiftUI +import SwiftUIHosting +import SwiftUISupport +import MondrianLayout + +struct BookRotateScrollView: View, PreviewProvider { + var body: some View { + ContentView() + } + + static var previews: some View { + Self() + } + + private struct ContentView: View { + + @State var uiView: ContainerView = .init(frame: .zero) + + var body: some View { + VStack { + Representable() + } + .navigationBarTitleDisplayMode(.inline) + } + } + + struct Representable: UIViewRepresentable { + + func makeUIView(context: Context) -> ContainerView { + ContainerView() + } + + func updateUIView(_ uiView: ContainerView, context: Context) { + + } + } + + class ContainerView: UIView, UIScrollViewDelegate { + + let scrollView = UIScrollView() + let imageView = UIImageView(image: UIImage(named: "horizontal-rect")!) + private let manualLayoutView = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + + scrollView.backgroundColor = .black + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.frame = frame.insetBy(dx: 30, dy: 30) + + addSubview(scrollView) + + scrollView.addSubview(imageView) + scrollView.contentSize = imageView.bounds.size + scrollView.delegate = self + +// scrollView.transform = .init(rotationAngle: Angle(degrees: 40).radians) + +// print(imageView.bounds.size) + + backgroundColor = .red + + manualLayoutView.backgroundColor = .systemGray + + Mondrian.buildSubviews(on: self) { + VStackBlock { + manualLayoutView + SwiftUIHostingView { + HStack { + Button("Action") { + + } + } + } + } + } + + } + + override func layoutSubviews() { + super.layoutSubviews() + + scrollView.frame = bounds + scrollView.minimumZoomScale = 0.01 + scrollView.maximumZoomScale = 100 + scrollView.contentInset = .init(top: 100, left: 100, bottom: 100, right: 100 ) + + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + } + +} diff --git a/Dev/Tests/BrightroomEngineTests/RendererTests.swift b/Dev/Tests/BrightroomEngineTests/RendererTests.swift index c2ad690a..6edf868d 100644 --- a/Dev/Tests/BrightroomEngineTests/RendererTests.swift +++ b/Dev/Tests/BrightroomEngineTests/RendererTests.swift @@ -175,7 +175,7 @@ final class RendererTests: XCTestCase { var crop = EditingCrop(imageSize: imageSource.readImageSize()) // crop.rotation = .angle_90 - crop.updateCropExtentNormalizing( + crop.updateCropExtent( .init(x: 854.0, y: 1766.0, width: 2863.0, height: 2863.0), respectingAspectRatio: nil ) diff --git a/Sources/BrightroomEngine/Core/DrawnPath.swift b/Sources/BrightroomEngine/Core/DrawnPath.swift index 7d0c246d..100cbf1d 100644 --- a/Sources/BrightroomEngine/Core/DrawnPath.swift +++ b/Sources/BrightroomEngine/Core/DrawnPath.swift @@ -75,8 +75,11 @@ public struct DrawnPath : GraphicsDrawing, Equatable { let boundingBox = context.boundingBoxOfClipPath context.scaleBy(x: 1, y: -1) - context.translateBy(x: 0, y: -(boundingBox.maxY + boundingBox.minY)) - assert(context.boundingBoxOfClipPath == boundingBox) + context.translateBy(x: 0, y: -(boundingBox.maxY + boundingBox.minY)) + + if context.boundingBoxOfClipPath == boundingBox { + EngineLog.error(.renderer, "Not exactly same bounding box. this may affects in artifacts.") + } brush.color.setStroke() let bezierPath = brushedPath() diff --git a/Sources/BrightroomEngine/Core/EditingCrop.swift b/Sources/BrightroomEngine/Core/EditingCrop.swift index ebc89141..efd142b3 100644 --- a/Sources/BrightroomEngine/Core/EditingCrop.swift +++ b/Sources/BrightroomEngine/Core/EditingCrop.swift @@ -21,6 +21,7 @@ import UIKit import Vision +import SwiftUI /// A representation of cropping extent in Image. public struct EditingCrop: Equatable { @@ -37,21 +38,21 @@ public struct EditingCrop: Equatable { /// 270 degree case angle_270 - public var angle: CGFloat { + public var angle: AdjustmentAngle { switch self { case .angle_0: - return 0 + return .degrees(0) case .angle_90: - return -CGFloat.pi / 2 + return .degrees(-90) case .angle_180: - return -CGFloat.pi + return .degrees(-180) case .angle_270: - return CGFloat.pi / 2 + return .degrees(-270) } } public var transform: CGAffineTransform { - .init(rotationAngle: angle) + .init(rotationAngle: angle.radians) } public func next() -> Self { @@ -64,6 +65,8 @@ public struct EditingCrop: Equatable { } } + public typealias AdjustmentAngle = SwiftUI.Angle + /// The dimensions in pixel for the image. /// Applied image-orientation. public var imageSize: CGSize @@ -74,6 +77,13 @@ public struct EditingCrop: Equatable { /// The angle that specifies rotation for the image. public var rotation: Rotation = .angle_0 + /// An angle to rotate in addition to the specified rotation. + public var adjustmentAngle: AdjustmentAngle = .zero + + public var aggregatedRotation: AdjustmentAngle { + rotation.angle + adjustmentAngle + } + public private(set) var scaleToRestore: CGFloat public init(from ciImage: CIImage) { @@ -124,7 +134,7 @@ public struct EditingCrop: Equatable { return new } - private func scaled(_ scale: CGFloat) -> Self { + private consuming func scaled(_ scale: CGFloat) -> Self { var modified = self @@ -230,15 +240,16 @@ public struct EditingCrop: Equatable { /// - Parameters: /// - cropExtent: /// - respectingAspectRatio: - public mutating func updateCropExtentNormalizing( + public mutating func updateCropExtent( _ cropExtent: CGRect, respectingAspectRatio: PixelAspectRatio? ) { - self.cropExtent = Self.fittingRect( - rect: cropExtent, - in: imageSize, - respectingAspectRatio: respectingAspectRatio - ) +// self.cropExtent = Self.fittingRect( +// rect: cropExtent, +// in: imageSize, +// respectingAspectRatio: respectingAspectRatio +// ) + self.cropExtent = cropExtent } private static func fittingRect( diff --git a/Sources/BrightroomEngine/Engine/BrightRoomImageRenderer.swift b/Sources/BrightroomEngine/Engine/BrightRoomImageRenderer.swift index ca8e73b7..076d6a2a 100644 --- a/Sources/BrightroomEngine/Engine/BrightRoomImageRenderer.swift +++ b/Sources/BrightroomEngine/Engine/BrightRoomImageRenderer.swift @@ -188,33 +188,39 @@ public final class BrightRoomImageRenderer { EngineLog.debug(.renderer, "Start render in using CoreGraphics") /* - === - === - === + - load original image + - the image might not be suitable for rendering by orientation wise. */ EngineLog.debug(.renderer, "Load full resolution CGImage from ImageSource.") let sourceCGImage: CGImage = source.loadOriginalCGImage() /* - === - === - === + - Fix the image orientation */ EngineLog.debug(.renderer, "Fix orientation") let orientedImage = try sourceCGImage.oriented(orientation) /* - === - === - === + - Crops image + - Uses specified data + - Uses full size crop info if there's no request. */ - // TODO: Better management of orientation - let crop = edit.croppingRect ?? .init(imageSize: source.readImageSize().applying(cgOrientation: orientation)) + + let crop: EditingCrop = edit.croppingRect ?? EditingCrop( + imageSize: source + .readImageSize() + .applying(cgOrientation: orientation) // TODO: Better management of orientation + ) + EngineLog.debug(.renderer, "Crop CGImage with extent \(crop)") - let croppedImage = try orientedImage.croppedWithColorspace(to: crop.cropExtent) + /// Render image as full size + let croppedImage = try orientedImage.croppedWithColorspace( + to: crop.cropExtent, + adjustmentAngleRadians: crop.aggregatedRotation.radians + ) /* === @@ -223,7 +229,6 @@ public final class BrightRoomImageRenderer { */ EngineLog.debug(.renderer, "Resize if needed") - let resizedImage: CGImage switch options.resolution { @@ -240,9 +245,10 @@ public final class BrightRoomImageRenderer { */ EngineLog.debug(.renderer, "Rotation") - let rotatedImage = try resizedImage.rotated(rotation: crop.rotation) +// // TODO: should be better that combines crop and rotation into single operation. +// let rotatedImage = try resizedImage.rotated(rotation: crop.rotation) - return .init(cgImage: rotatedImage, options: options, engine: .coreGraphics) + return .init(cgImage: resizedImage, options: options, engine: .coreGraphics) } /** @@ -295,111 +301,76 @@ public final class BrightRoomImageRenderer { modifier.apply(to: image, sourceImage: sourceCIImage) } - /* - === - === - === - */ - EngineLog.debug(.renderer, "Applies Crop to effected image") - - // TODO: Better management of orientation - let crop = edit.croppingRect ?? .init(imageSize: source.readImageSize().applying(cgOrientation: orientation)) - - let cropped_effected_CIImage = effected_CIImage.cropped(to: crop) - - debug(cropped_effected_CIImage) - - /* - === - === - === - */ - EngineLog.debug(.renderer, "Creates CGImage from crop applied CIImage.") - /** To keep wide-color(DisplayP3), use createCGImage instead drawing with CIContext */ - let cropped_effected_CGImage = ciContext.createCGImage( - cropped_effected_CIImage, - from: cropped_effected_CIImage.extent, + let effected_CGImage = ciContext.createCGImage( + effected_CIImage, + from: effected_CIImage.extent, format: options.workingFormat, colorSpace: options.workingColorSpace ?? sourceCIImage.colorSpace, deferred: false )! - EngineLog.debug(.renderer, "Created effected CGImage => \(cropped_effected_CGImage)") - - /* - === - === - === - */ - let drawings_CGImage: CGImage if edit.drawer.isEmpty { EngineLog.debug(.renderer, "No drawings") - drawings_CGImage = cropped_effected_CGImage + drawings_CGImage = effected_CGImage } else { EngineLog.debug(.renderer, "Found drawings") /** Render drawings */ - drawings_CGImage = try CGContext.makeContext(for: cropped_effected_CGImage) + drawings_CGImage = try CGContext.makeContext(for: effected_CGImage) .perform { c in c.draw( - cropped_effected_CGImage, - in: .init(origin: .zero, size: cropped_effected_CGImage.size) + effected_CGImage, + in: .init(origin: .zero, size: effected_CGImage.size) ) - c.translateBy(x: -crop.cropExtent.origin.x, y: -crop.cropExtent.origin.y) self.edit.drawer.forEach { drawer in drawer.draw(in: c) } + } .makeImage() .unwrap() } + let crop: EditingCrop = edit.croppingRect ?? EditingCrop( + imageSize: source + .readImageSize() + .applying(cgOrientation: orientation) // TODO: Better management of orientation + ) + /// Render image as full size + let croppedImage = try drawings_CGImage.croppedWithColorspace( + to: crop.cropExtent, + adjustmentAngleRadians: crop.aggregatedRotation.radians + ) + /* === === === */ + EngineLog.debug(.renderer, "Resize if needed") let resizedImage: CGImage switch options.resolution { case .full: - - EngineLog.debug(.renderer, "No resizing") - - resizedImage = drawings_CGImage - - case let .resize(maxPixelSize): - - EngineLog.debug(.renderer, "Resizing with maxPixelSize: \(maxPixelSize)") - - resizedImage = try drawings_CGImage.resized(maxPixelSize: maxPixelSize) - + resizedImage = croppedImage + case .resize(let maxPixelSize): + resizedImage = try croppedImage.resized(maxPixelSize: maxPixelSize) } - /* - === - === - === - */ - - EngineLog.debug(.renderer, "Rotates image if needed") - - let rotatedImage = try resizedImage.rotated(rotation: crop.rotation) - let duration = CACurrentMediaTime() - startTime EngineLog.debug(.renderer, "Rendering has completed - took \(duration * 1000)ms") - return .init(cgImage: rotatedImage, options: options, engine: .combined) + return .init(cgImage: resizedImage, options: options, engine: .combined) } } diff --git a/Sources/BrightroomEngine/Engine/CoreGraphics+.swift b/Sources/BrightroomEngine/Engine/CoreGraphics+.swift index e686dc21..101d6387 100644 --- a/Sources/BrightroomEngine/Engine/CoreGraphics+.swift +++ b/Sources/BrightroomEngine/Engine/CoreGraphics+.swift @@ -25,7 +25,7 @@ import ImageIO extension CGContext { @discardableResult - func perform(_ drawing: (CGContext) -> Void) -> CGContext { + consuming func perform(_ drawing: (borrowing CGContext) -> Void) -> CGContext { drawing(self) return self } @@ -100,20 +100,49 @@ extension CGContext { } } +extension CGContext { + + /** + around center: use center of boundingBoxOfClipPath + */ + func rotate(radians: CGFloat, anchor: CGPoint) { + + print(anchor) + + translateBy(x: anchor.x, y: anchor.y) + rotate(by: radians) + translateBy(x: -anchor.x, y: -anchor.y) + + } +} + extension CGImage { var size: CGSize { return .init(width: width, height: height) } - func croppedWithColorspace(to cropRect: CGRect) throws -> CGImage { + func croppedWithColorspace( + to cropRect: CGRect, + adjustmentAngleRadians: CGFloat + ) throws -> CGImage { let cgImage = try autoreleasepool { () -> CGImage? in let context = try CGContext.makeContext(for: self, size: cropRect.size) - .perform { c in + .perform { context in + + let flippedRect = CGRect(x: cropRect.minX, y: size.height - cropRect.maxY, width: cropRect.width, height: cropRect.height) + print(flippedRect) + + context.rotate( + radians: -adjustmentAngleRadians, +// radians: EditingCrop.AdjustmentAngle(degrees: -20).radians, + anchor: .init(x: context.boundingBoxOfClipPath.midX, y: context.boundingBoxOfClipPath.midY) +// anchor: .init(x: flippedRect.midX, y: flippedRect.midY) + ) - c.draw( + context.draw( self, in: CGRect( origin: .init( @@ -233,7 +262,7 @@ extension CGImage { func rotated(rotation: EditingCrop.Rotation, flipping: Flipping? = nil) throws -> CGImage { - try rotated(angle: -rotation.angle, flipping: flipping) + try rotated(angle: -rotation.angle.radians, flipping: flipping) } } diff --git a/Sources/BrightroomEngine/Library/EngineLog.swift b/Sources/BrightroomEngine/Library/EngineLog.swift index 785c1eea..42153526 100644 --- a/Sources/BrightroomEngine/Library/EngineLog.swift +++ b/Sources/BrightroomEngine/Library/EngineLog.swift @@ -24,31 +24,31 @@ import UIKit import os.log enum EngineLog { - + private static let osLog = OSLog.init(subsystem: "PixelEngine", category: "Engine") - + static func debug(_ object: Any...) { - #if DEBUG +#if DEBUG if #available(iOS 12.0, *) { os_log(.debug, log: osLog, "%@", object.map { "\($0)" }.joined(separator: " ")) } else { os_log("%@", log: osLog, type: .debug, object.map { "\($0)" }.joined(separator: " ")) } - #endif +#endif } - + static func debug(_ log: OSLog, _ object: Any...) { os_log(.debug, log: log, "%@", object.map { "\($0)" }.joined(separator: " ")) } - + static func error(_ log: OSLog, _ object: Any...) { os_log(.error, log: log, "%@", object.map { "\($0)" }.joined(separator: " ")) } - + } extension OSLog { - + static let renderer = OSLog.init(subsystem: "BrightroomEngine", category: "🎨 Renderer") static let stack = OSLog.init(subsystem: "BrightroomEngine", category: "🥞 Stack") 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/Built-in UI/ClassicImageEdit/ClassicImageEditOptions.swift b/Sources/BrightroomUI/Built-in UI/ClassicImageEdit/ClassicImageEditOptions.swift index 40bb17b2..966b5647 100644 --- a/Sources/BrightroomUI/Built-in UI/ClassicImageEdit/ClassicImageEditOptions.swift +++ b/Sources/BrightroomUI/Built-in UI/ClassicImageEdit/ClassicImageEditOptions.swift @@ -31,12 +31,20 @@ public struct ClassicImageEditOptions { public static var current: ClassicImageEditOptions = .init() - public var croppingAspectRatio: PixelAspectRatio? = .square - public var isFaceDetectionEnabled: Bool = false + public var croppingAspectRatio: PixelAspectRatio? + public var isFaceDetectionEnabled: Bool - public var classes: Classes = .init() + public var classes: Classes - public init() {} + public init( + croppingAspectRatio: PixelAspectRatio? = .square, + isFaceDetectionEnabled: Bool = false, + classes: Classes = .init() + ) { + self.croppingAspectRatio = croppingAspectRatio + self.isFaceDetectionEnabled = isFaceDetectionEnabled + self.classes = classes + } } extension ClassicImageEditOptions { diff --git a/Sources/BrightroomUI/Built-in UI/ClassicImageEdit/ClassicImageEditViewController.swift b/Sources/BrightroomUI/Built-in UI/ClassicImageEdit/ClassicImageEditViewController.swift index d99e6ace..0318ecb0 100644 --- a/Sources/BrightroomUI/Built-in UI/ClassicImageEdit/ClassicImageEditViewController.swift +++ b/Sources/BrightroomUI/Built-in UI/ClassicImageEdit/ClassicImageEditViewController.swift @@ -118,8 +118,9 @@ public final class ClassicImageEditViewController: UIViewController { private lazy var loadingView = LoadingBlurryOverlayView( effect: UIBlurEffect(style: .dark), - activityIndicatorStyle: .whiteLarge + activityIndicatorStyle: .large ) + private lazy var touchGuardOverlayView = UIView() private let viewModel: ClassicImageEditViewModel @@ -301,7 +302,7 @@ public final class ClassicImageEditViewController: UIViewController { cropView.store.sinkState { [viewModel] state in - state.ifChanged(\.proposedCrop) { value in + state.ifChanged(\.proposedCrop).do { value in guard let value = value else { return } viewModel.setProposedCrop(value) } @@ -329,20 +330,15 @@ public final class ClassicImageEditViewController: UIViewController { } private func updateUI(state: Changes) { - state.ifChanged(\.title) { title in + state.ifChanged(\.title).do { title in navigationItem.title = title } - state.ifChanged(\.maskingBrushSize) { + state.ifChanged(\.maskingBrushSize).do { maskingView.setBrushSize($0) } - state.ifChanged(\.proposedCrop) { value in - guard let value = value else { return } - cropView.setCrop(value) - } - - state.ifChanged(\.mode) { mode in + state.ifChanged(\.mode).do { mode in switch mode { case .crop: @@ -398,7 +394,7 @@ public final class ClassicImageEditViewController: UIViewController { let editingState = state.map(\.editingState) - editingState.ifChanged(\.isLoading) { isLoading in + editingState.ifChanged(\.isLoading).do { isLoading in switch isLoading { case true: diff --git a/Sources/BrightroomUI/Built-in UI/PhotosCrop/PhotosCropAspectRatioControl.swift b/Sources/BrightroomUI/Built-in UI/PhotosCrop/PhotosCropAspectRatioControl.swift index ab81ec32..aa5ebdce 100644 --- a/Sources/BrightroomUI/Built-in UI/PhotosCrop/PhotosCropAspectRatioControl.swift +++ b/Sources/BrightroomUI/Built-in UI/PhotosCrop/PhotosCropAspectRatioControl.swift @@ -59,6 +59,7 @@ final class PhotosCropAspectRatioControl: PixelEditorCodeBasedView { } /** + A rectangle whose width is longer than its height +---------------+ | | | | @@ -76,8 +77,16 @@ final class PhotosCropAspectRatioControl: PixelEditorCodeBasedView { let originalAspectRatio: PixelAspectRatio let originalDirection: Direction - var selectedAspectRatio: PixelAspectRatio? - + var selectedAspectRatio: PixelAspectRatio? { + didSet { + guard oldValue != selectedAspectRatio else { return } + + if let selectedAspectRatio { + direction = selectedAspectRatio.height > selectedAspectRatio.width ? .vertical : .horizontal + } + } + } + var direction: Direction { didSet { if let selectedAspectRatio = selectedAspectRatio { @@ -88,8 +97,6 @@ final class PhotosCropAspectRatioControl: PixelEditorCodeBasedView { } } - var rotation: EditingCrop.Rotation = .angle_0 - var canSelectDirection: Bool { guard let selectedAspectRatio = selectedAspectRatio else { return false @@ -197,23 +204,13 @@ final class PhotosCropAspectRatioControl: PixelEditorCodeBasedView { horizontalButton.onTap { [unowned self] in store.commit { - switch $0.rotation { - case .angle_0, .angle_180: - $0.direction = .horizontal - case .angle_90, .angle_270: - $0.direction = .vertical - } + $0.direction = .horizontal } } verticalButton.onTap { [unowned self] in store.commit { - switch $0.rotation { - case .angle_0, .angle_180: - $0.direction = .vertical - case .angle_90, .angle_270: - $0.direction = .horizontal - } + $0.direction = .vertical } } @@ -286,8 +283,8 @@ final class PhotosCropAspectRatioControl: PixelEditorCodeBasedView { guard let self = self else { return } - state.ifChanged(\.selectedAspectRatio) { selected in - + state.ifChanged(\.selectedAspectRatio).do { selected in + guard let selected = selected else { // Freeform @@ -316,25 +313,16 @@ final class PhotosCropAspectRatioControl: PixelEditorCodeBasedView { } - state.ifChanged(\.canSelectDirection) { canSelectDirection in - + state.ifChanged(\.canSelectDirection).do { canSelectDirection in + self.horizontalButton.isEnabled = canSelectDirection self.verticalButton.isEnabled = canSelectDirection } - state.ifChanged(\.direction, \.rotation) { direction, rotation in - - var d = direction - - switch rotation { - case .angle_0, .angle_180: - break - case .angle_90, .angle_270: - d.swap() - } - + state.ifChanged(\.direction).do { direction in + /// Changes display according to image's rectangle direction. - switch d { + switch direction { case .horizontal: self.horizontalButton.isSelected = true @@ -353,9 +341,7 @@ final class PhotosCropAspectRatioControl: PixelEditorCodeBasedView { buttons[ratioValue(from: ratio)]?.setTitle("\(Int(ratio.height)):\(Int(ratio.width))", for: .normal) } } - - - + } } .store(in: &subscriptions) @@ -379,18 +365,6 @@ final class PhotosCropAspectRatioControl: PixelEditorCodeBasedView { } } - func setRotation(_ rotation: EditingCrop.Rotation) { - - isSupressingHandlers = true - defer { - isSupressingHandlers = false - } - - store.commit { - $0.rotation = rotation - } - - } } private final class AspectRatioButton: UIButton { diff --git a/Sources/BrightroomUI/Built-in UI/PhotosCrop/PhotosCropViewController.swift b/Sources/BrightroomUI/Built-in UI/PhotosCrop/PhotosCropViewController.swift index 3052978f..4f4270bf 100644 --- a/Sources/BrightroomUI/Built-in UI/PhotosCrop/PhotosCropViewController.swift +++ b/Sources/BrightroomUI/Built-in UI/PhotosCrop/PhotosCropViewController.swift @@ -263,7 +263,7 @@ public final class PhotosCropViewController: UIViewController { self.setUpLoadedUI(state: state.primitive) - state.ifChanged(\.hasUncommitedChanges) { hasChanges in + state.ifChanged(\.hasUncommitedChanges).do { hasChanges in self.resetButton.isHidden = !hasChanges } @@ -341,13 +341,8 @@ public final class PhotosCropViewController: UIViewController { cropView.store.sinkState { [weak self] (state) in guard let self = self else { return } - - state.ifChanged(\.proposedCrop?.rotation) { rotation in - guard let rotation = rotation else { return } - self.aspectRatioControl?.setRotation(rotation) - } - - state.ifChanged(\.preferredAspectRatio) { ratio in + + state.ifChanged(\.preferredAspectRatio).do { ratio in self.aspectRatioControl?.setSelected(ratio) } @@ -360,7 +355,7 @@ public final class PhotosCropViewController: UIViewController { let options = state.map(\.options) - options.ifChanged(\.aspectRatioOptions) { value in + options.ifChanged(\.aspectRatioOptions).do { value in switch value { case .fixed(let aspectRatio): aspectRatioButton.alpha = 0 @@ -371,7 +366,7 @@ public final class PhotosCropViewController: UIViewController { } } - state.ifChanged(\.isSelectingAspectRatio) { value in + state.ifChanged(\.isSelectingAspectRatio).do { value in UIViewPropertyAnimator.init(duration: 0.4, dampingRatio: 1) { [self] in if value { aspectRatioControl?.alpha = 1 @@ -390,9 +385,16 @@ public final class PhotosCropViewController: UIViewController { guard let proposedCrop = cropView.store.state.proposedCrop else { return } - + let rotation = proposedCrop.rotation.next() cropView.setRotation(rotation) + + if let ratio = cropView.store.state.preferredAspectRatio { + cropView.setCroppingAspectRatio(ratio.swapped()) + } else { + // CropView update automatically + } + } @objc private func handleAspectRatioButton() { diff --git a/Sources/BrightroomUI/Shared/Components/Crop/CropView.CropInsideOverlay.swift b/Sources/BrightroomUI/Shared/Components/Crop/CropView.CropInsideOverlay.swift index 9f1b8fa2..07e8b85c 100644 --- a/Sources/BrightroomUI/Shared/Components/Crop/CropView.CropInsideOverlay.swift +++ b/Sources/BrightroomUI/Shared/Components/Crop/CropView.CropInsideOverlay.swift @@ -32,6 +32,7 @@ extension CropView { isUserInteractionEnabled = false + edgeShapeLayer.accessibilityIdentifier = "Edge" addSubview(edgeShapeLayer) [ cornerTopLeftHorizontalShapeLayer, diff --git a/Sources/BrightroomUI/Shared/Components/Crop/CropView._CropScrollView.swift b/Sources/BrightroomUI/Shared/Components/Crop/CropView._CropScrollView.swift index d7bc960c..a2289f8a 100644 --- a/Sources/BrightroomUI/Shared/Components/Crop/CropView._CropScrollView.swift +++ b/Sources/BrightroomUI/Shared/Components/Crop/CropView._CropScrollView.swift @@ -40,10 +40,11 @@ extension CropView { private func initialize() { if #available(iOS 11.0, *) { - contentInsetAdjustmentBehavior = .never + contentInsetAdjustmentBehavior = .never } else { // Fallback on earlier versions } + insetsLayoutMarginsFromSafeArea = false showsVerticalScrollIndicator = false showsHorizontalScrollIndicator = false bouncesZoom = true @@ -54,5 +55,66 @@ extension CropView { scrollsToTop = false } } - + + final class ImagePlatterView: UIView { + + #if DEBUG + private let debugShapeLayer: CAShapeLayer = { + let layer = CAShapeLayer() + layer.strokeColor = UIColor.systemBlue.cgColor + layer.lineWidth = 2 + layer.fillColor = nil + return layer + }() + #endif + + var image: UIImage? { + get { + imageView.image + } + set { + imageView.image = newValue + } + } + + let imageView: UIImageView + + var overlay: UIView? { + didSet { + oldValue?.removeFromSuperview() + if let overlay { + addSubview(overlay) + } + } + } + + override init(frame: CGRect) { + self.imageView = _ImageView() + super.init(frame: frame) + + addSubview(imageView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + imageView.frame = bounds + overlay?.frame = bounds + #if DEBUG + layer.addSublayer(debugShapeLayer) + debugShapeLayer.frame = bounds + #endif + } + + func _debug_setPath(path: UIBezierPath) { + #if DEBUG + debugShapeLayer.path = path.cgPath + #endif + } + + } + } diff --git a/Sources/BrightroomUI/Shared/Components/Crop/CropView._InteractiveCropGuideView.swift b/Sources/BrightroomUI/Shared/Components/Crop/CropView._InteractiveCropGuideView.swift index e98cfbd5..6542b64e 100644 --- a/Sources/BrightroomUI/Shared/Components/Crop/CropView._InteractiveCropGuideView.swift +++ b/Sources/BrightroomUI/Shared/Components/Crop/CropView._InteractiveCropGuideView.swift @@ -43,7 +43,9 @@ extension CropView { } final class _InteractiveCropGuideView: PixelEditorCodeBasedView, UIGestureRecognizerDelegate { + var willChange: () -> Void = {} + var updating: () -> Void = {} var didChange: () -> Void = {} private let topLeftControlPointView = TapExpandedView(horizontal: 16, vertical: 16) @@ -293,7 +295,6 @@ extension CropView { } assert(view.superview != nil) - assert(view.superview is CropView) cropOutsideOverlay = view @@ -318,8 +319,7 @@ extension CropView { outOfBoundsOverlayView.mask = invertedMaskShapeLayerView } } - -// EditorLog.debug("[CropGuide] \(frame)") + } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { @@ -349,8 +349,27 @@ extension CropView { @inline(__always) private func updateMaximumRect() { - maximumRect = imageView.convert(imageView.bounds, to: containerView) + + let insets = containerView.remainingScroll + + let reversedInsets = UIEdgeInsets( + top: -insets.top, + left: -insets.left, + bottom: -insets.bottom, + right: -insets.right + ) + + let r = self.frame + .inset(by: reversedInsets) .intersection(containerView.bounds.inset(by: insetOfGuideFlexibility)) + + maximumRect = r + + leftMaxConstraint?.constant = r.minX + rightMaxConstraint?.constant = maximumRect!.maxX - superview!.bounds.maxX + topMaxConstraint?.constant = r.minY + bottomMaxConstraint?.constant = maximumRect!.maxY - superview!.bounds.maxY + } private var isTracking = false @@ -367,6 +386,11 @@ extension CropView { cropOutsideOverlay?.didBeginAdjustment(kind: .guide) } + private func onGestureTrackingChanged() { + updating() + updateMaximumRect() + } + private func onGestureTrackingEnded() { isTracking = false @@ -380,6 +404,11 @@ extension CropView { private var widthConstraint: NSLayoutConstraint! private var heightConstraint: NSLayoutConstraint! + private var leftMaxConstraint: NSLayoutConstraint? + private var rightMaxConstraint: NSLayoutConstraint? + private var topMaxConstraint: NSLayoutConstraint? + private var bottomMaxConstraint: NSLayoutConstraint? + private var activeConstraints: [NSLayoutConstraint] = [] private func activateRightConstraint() { @@ -398,52 +427,61 @@ extension CropView { private func activateLeftMaxConstraint() { translatesAutoresizingMaskIntoConstraints = false + leftMaxConstraint = leftAnchor.constraint( + greaterThanOrEqualTo: superview!.leftAnchor, + constant: maximumRect!.minX + )&>.do { + $0.isActive = true + } + activeConstraints.append( - leftAnchor.constraint( - greaterThanOrEqualTo: superview!.leftAnchor, - constant: maximumRect!.minX - )&>.do { - $0.isActive = true - } + leftMaxConstraint! ) } private func activateRightMaxConstraint() { translatesAutoresizingMaskIntoConstraints = false + rightMaxConstraint = rightAnchor.constraint( + lessThanOrEqualTo: superview!.rightAnchor, + constant: maximumRect!.maxX - superview!.bounds.maxX + )&>.do { + $0.isActive = true + } + activeConstraints.append( - rightAnchor.constraint( - lessThanOrEqualTo: superview!.rightAnchor, - constant: maximumRect!.maxX - superview!.bounds.maxX - )&>.do { - $0.isActive = true - } + rightMaxConstraint! ) } private func activateTopMaxConstraint() { translatesAutoresizingMaskIntoConstraints = false + topMaxConstraint = topAnchor.constraint( + greaterThanOrEqualTo: superview!.topAnchor, + constant: maximumRect!.minY + )&>.do { + $0.isActive = true + } + activeConstraints.append( - topAnchor.constraint( - greaterThanOrEqualTo: superview!.topAnchor, - constant: maximumRect!.minY - )&>.do { - $0.isActive = true - } + topMaxConstraint! ) } private func activateBottomMaxConstraint() { translatesAutoresizingMaskIntoConstraints = false + bottomMaxConstraint = bottomAnchor.constraint( + lessThanOrEqualTo: superview!.bottomAnchor, + constant: maximumRect!.maxY - superview!.bounds.maxY + )&>.do { + $0.isActive = true + } + activeConstraints.append( - bottomAnchor.constraint( - lessThanOrEqualTo: superview!.bottomAnchor, - constant: maximumRect!.maxY - superview!.bounds.maxY - )&>.do { - $0.isActive = true - }) + bottomMaxConstraint! + ) } private func activateLeftConstraint() { @@ -595,6 +633,8 @@ extension CropView { widthConstraint.constant -= translation.x heightConstraint.constant -= translation.y + onGestureTrackingChanged() + case .cancelled, .ended, .failed: @@ -611,6 +651,7 @@ extension CropView { switch gesture.state { case .began: + onGestureTrackingStarted() activateConstraints: do { @@ -636,6 +677,7 @@ extension CropView { widthConstraint.constant += translation.x heightConstraint.constant -= translation.y + onGestureTrackingChanged() case .cancelled, .ended, .failed: @@ -678,6 +720,7 @@ extension CropView { widthConstraint.constant -= translation.x heightConstraint.constant += translation.y + onGestureTrackingChanged() case .cancelled, .ended, .failed: @@ -719,6 +762,8 @@ extension CropView { widthConstraint.constant += translation.x heightConstraint.constant += translation.y + onGestureTrackingChanged() + case .cancelled, .ended, .failed: @@ -764,6 +809,7 @@ extension CropView { heightConstraint.constant -= translation.y + onGestureTrackingChanged() case .cancelled, .ended, .failed: @@ -808,6 +854,7 @@ extension CropView { widthConstraint.constant += translation.x + onGestureTrackingChanged() case .cancelled, .ended, .failed: @@ -853,6 +900,7 @@ extension CropView { widthConstraint.constant -= translation.x + onGestureTrackingChanged() case .cancelled, .ended, .failed: @@ -897,6 +945,7 @@ extension CropView { heightConstraint.constant += translation.y + onGestureTrackingChanged() case .cancelled, .ended, .failed: diff --git a/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift b/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift index 4a8cc75c..8f5adf85 100644 --- a/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift +++ b/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift @@ -20,7 +20,7 @@ // THE SOFTWARE. import CoreImage - +import SwiftUI import UIKit import Verge @@ -28,17 +28,16 @@ import Verge import BrightroomEngine #endif -/** - A view that previews how crops the image. - - The cropping adjustument is avaibleble from 2 ways: - - Scrolling image - - Panning guide - - - TODO: - - Implicit animations occurs in first time load with remote image. - */ +/// A view that previews how crops the image. +/// +/// The cropping adjustument is avaibleble from 2 ways: +/// - Scrolling image +/// - Panning guide +/// +/// - TODO: +/// - Implicit animations occurs in first time load with remote image. public final class CropView: UIView, UIScrollViewDelegate { + public struct State: Equatable { public enum AdjustmentKind: Equatable { case scrollView @@ -48,17 +47,16 @@ public final class CropView: UIView, UIScrollViewDelegate { public fileprivate(set) var proposedCrop: EditingCrop? public fileprivate(set) var frame: CGRect = .zero - - fileprivate var isGuideInteractionEnabled: Bool = true + fileprivate var layoutVersion: UInt64 = 0 - + /** Returns aspect ratio. Would not be affected by rotation. */ var preferredAspectRatio: PixelAspectRatio? } - + /** A view that covers the area out of cropping extent. */ @@ -72,33 +70,80 @@ public final class CropView: UIView, UIScrollViewDelegate { */ public var isGuideInteractionEnabled: Bool { get { - store.state.isGuideInteractionEnabled + guideView.isUserInteractionEnabled } set { + self.guideView.isUserInteractionEnabled = newValue + } + } + + /** + Clips ScrollView to guide view. + */ + public var clipsToGuide: Bool = false { + didSet { store.commit { - $0.isGuideInteractionEnabled = newValue + $0.layoutVersion += 1 } } } - + + public var isImageViewHidden: Bool { + get { + imagePlatterView.imageView.isHidden + } + set { + imagePlatterView.imageView.isHidden = newValue + } + } + + public var isZoomEnabled: Bool = true { + didSet { + store.commit { + $0.layoutVersion += 1 + } + } + } + + public var isScrollEnabled: Bool { + get { + scrollView.isScrollEnabled + } + set { + scrollView.isScrollEnabled = newValue + } + } + public let editingStack: EditingStack /** An image view that displayed in the scroll view. */ - private let imageView = UIImageView() - + private let imagePlatterView = ImagePlatterView() + + private let scrollPlatterView = UIView() + + #if DEBUG + private let _debug_shapeLayer: CAShapeLayer = { + let layer = CAShapeLayer() + layer.strokeColor = UIColor.red.cgColor + layer.fillColor = UIColor.clear.cgColor + layer.lineWidth = 2 + return layer + }() + #endif + /** Internal scroll view */ private let scrollView = _CropScrollView() - + /** A background view for scroll view. It provides the frame to scroll view. */ private let scrollBackdropView = UIView() - + private var hasSetupScrollViewCompleted = false /** @@ -106,10 +151,45 @@ public final class CropView: UIView, UIScrollViewDelegate { */ private lazy var guideView = _InteractiveCropGuideView( containerView: self, - imageView: self.imageView, + imageView: self.imagePlatterView, insetOfGuideFlexibility: contentInset ) + private let guideMaximumView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + view.accessibilityIdentifier = "maximumView" + return view + }() + + // for now, for debugging + private let guideShadowingView: UIView = { + let view = UIView() + // #if DEBUG + // view.backgroundColor = .systemYellow.withAlphaComponent(0.5) + // #endif + view.isUserInteractionEnabled = false + view.accessibilityIdentifier = "guideShadowingView" + return view + }() + + private let guideBackdropView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + view.accessibilityIdentifier = "guideBackdropView" + return view + }() + + private let guideOutsideContainerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + view.accessibilityIdentifier = "guideOutsideContainerView" + return view + }() + private var subscriptions = Set() /// A throttling timer to apply guide changed event. @@ -118,14 +198,14 @@ public final class CropView: UIView, UIScrollViewDelegate { private let debounce = _BrightroomDebounce(interval: 0.8) private let contentInset: UIEdgeInsets - + private var loadingOverlayFactory: (() -> UIView)? private weak var currentLoadingOverlay: UIView? - + private var isBinding = false - + var isAutoApplyEditingStackEnabled = false - + // MARK: - Initializers /** @@ -154,21 +234,27 @@ public final class CropView: UIView, UIScrollViewDelegate { self.editingStack = editingStack self.contentInset = contentInset - + self.store = .init(initialState: .init(), logger: nil) super.init(frame: .zero) - + scrollBackdropView.accessibilityIdentifier = "scrollBackdropView" clipsToBounds = false - addSubview(scrollBackdropView) - addSubview(scrollView) + addSubview(scrollPlatterView) + scrollPlatterView.addSubview(scrollBackdropView) + scrollPlatterView.addSubview(scrollView) + + addSubview(guideOutsideContainerView) + addSubview(guideMaximumView) + addSubview(guideShadowingView) + addSubview(guideBackdropView) addSubview(guideView) - imageView.isUserInteractionEnabled = true - scrollView.addSubview(imageView) + imagePlatterView.isUserInteractionEnabled = true + scrollView.addSubview(imagePlatterView) scrollView.delegate = self guideView.didChange = { [weak self] in @@ -176,6 +262,14 @@ public final class CropView: UIView, UIScrollViewDelegate { self.didChangeGuideViewWithDelay() } + guideView.updating = { [weak self] in + guard let self else { return } + guard let currentProposedCrop = store.state.proposedCrop else { + return + } + // updateScrollViewInset(crop: currentProposedCrop) + } + guideView.willChange = { [weak self] in guard let self = self else { return } self.willChangeGuideView() @@ -187,12 +281,13 @@ public final class CropView: UIView, UIScrollViewDelegate { } .store(in: &subscriptions) #endif - - defaultAppearance: do { + + // apply defaultAppearance + do { setCropInsideOverlay(CropView.CropInsideOverlayRuleOfThirdsView()) setCropOutsideOverlay(CropView.CropOutsideOverlayBlurredView()) setLoadingOverlay(factory: { - LoadingBlurryOverlayView(effect: UIBlurEffect(style: .dark), activityIndicatorStyle: .whiteLarge) + LoadingBlurryOverlayView(effect: UIBlurEffect(style: .dark), activityIndicatorStyle: .large) }) } } @@ -203,43 +298,50 @@ public final class CropView: UIView, UIScrollViewDelegate { } // MARK: - Functions - + + public func setOverlayInImageView(_ overlay: UIView) { + imagePlatterView.overlay = overlay + } + public override func willMove(toSuperview newSuperview: UIView?) { super.willMove(toSuperview: newSuperview) - + if isBinding == false { isBinding = true editingStack.start() - + binding: do { store.sinkState(queue: .mainIsolated()) { [weak self] state in - + guard let self = self else { return } - + state.ifChanged({ ( $0.frame, $0.layoutVersion ) - }, .any(==)) { (frame, _) in - + }).do { (frame, _) in + guard let crop = state.proposedCrop else { return } - + guard frame != .zero else { return } - + setupScrollViewOnce: do { if self.hasSetupScrollViewCompleted == false { self.hasSetupScrollViewCompleted = true - + + self.imagePlatterView.bounds = .init( + origin: .zero, + size: crop.scrollViewContentSize() + ) + let scrollView = self.scrollView - - self.imageView.bounds = .init(origin: .zero, size: crop.scrollViewContentSize()) - + // Do we need this? it seems ImageView's bounds changes contentSize automatically. not sure. UIView.performWithoutAnimation { let currentZoomScale = scrollView.zoomScale @@ -252,86 +354,80 @@ public final class CropView: UIView, UIScrollViewDelegate { } } } - } - + } + self.updateScrollContainerView( by: crop, preferredAspectRatio: state.preferredAspectRatio, animated: state.previous?.proposedCrop != nil /* whether first time load */, animatesRotation: state.hasChanges(\.proposedCrop?.rotation) ) - + } - + if self.isAutoApplyEditingStackEnabled { - state.ifChanged(\.proposedCrop) { crop in + state.ifChanged(\.proposedCrop).do { crop in guard let crop = crop else { return } self.editingStack.crop(crop) } } - - state.ifChanged(\.isGuideInteractionEnabled) { value in - self.guideView.isUserInteractionEnabled = value - } + } .store(in: &subscriptions) - - var appliedCrop = false - + // To restore current crop from editing-stack editingStack.sinkState { [weak self] state in - + guard let self = self else { return } - + if let loaded = state.mapIfPresent(\.loadedState) { - - loaded.ifChanged(\.imageForCrop) { image in + + loaded.ifChanged(\.imageForCrop).do { image in self.setImage(image) } - - if appliedCrop == false { - appliedCrop = true + + loaded.ifChanged(\.currentEdit.crop).do { crop in self.setCrop(loaded.currentEdit.crop) } - + } - - state.ifChanged(\.isLoading) { isLoading in + + state.ifChanged(\.isLoading).do { isLoading in self.updateLoadingState(displays: isLoading) } - + } .store(in: &subscriptions) } - + } - + } - + private func updateLoadingState(displays: Bool) { - + if displays, let factory = self.loadingOverlayFactory { - + guideView.alpha = 0 scrollView.alpha = 0 - + let loadingOverlay = factory() self.currentLoadingOverlay = loadingOverlay self.addSubview(loadingOverlay) AutoLayoutTools.setEdge(loadingOverlay, self) - + loadingOverlay.alpha = 0 UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { loadingOverlay.alpha = 1 } .startAnimation() - + } else { - + if let view = currentLoadingOverlay { - + layoutIfNeeded() UIViewPropertyAnimator(duration: 0.6, dampingRatio: 1) { view.alpha = 0 @@ -339,16 +435,16 @@ public final class CropView: UIView, UIScrollViewDelegate { self.scrollView.alpha = 1 }&>.do { $0.addCompletion { _ in - view.removeFromSuperview() + view.removeFromSuperview() } $0.startAnimation(afterDelay: 0.2) } } - + } - + } - + /** Renders an image according to the editing. @@ -358,7 +454,7 @@ public final class CropView: UIView, UIScrollViewDelegate { applyEditingStack() return try editingStack.makeRenderer().render() } - + /** Applies the current state to the EditingStack. */ @@ -390,14 +486,36 @@ public final class CropView: UIView, UIScrollViewDelegate { _pixeleditor_ensureMainThread() store.commit { - $0.proposedCrop?.rotation = rotation + + if let crop = $0.proposedCrop { + + $0.proposedCrop?.updateCropExtent( + crop.cropExtent.rotated((crop.rotation.angle - rotation.angle).radians), + respectingAspectRatio: nil + ) + + $0.proposedCrop?.rotation = rotation + } + $0.layoutVersion += 1 } + + } + + public func setAdjustmentAngle(_ angle: EditingCrop.AdjustmentAngle) { + + store.commit { + $0.proposedCrop?.adjustmentAngle = angle + $0.layoutVersion += 1 + } + + record() + } public func setCrop(_ crop: EditingCrop) { _pixeleditor_ensureMainThread() - + store.commit { guard $0.proposedCrop != crop else { return @@ -426,6 +544,8 @@ public final class CropView: UIView, UIScrollViewDelegate { } $0.layoutVersion += 1 } + + guideView.setLockedAspectRatio(ratio) } /** @@ -441,6 +561,22 @@ public final class CropView: UIView, UIScrollViewDelegate { guideView.setCropInsideOverlay(view) } + public func swapCropRectangleDirection() { + + store.commit { + + guard let crop = $0.proposedCrop else { + return + } + + $0.proposedCrop?.updateCropExtentIfNeeded( + toFitAspectRatio: PixelAspectRatio(crop.cropExtent.size).swapped() + ) + $0.layoutVersion += 1 + + } + } + /** Displays an overlay that covers the area out of cropping extent. Given view's frame would be adjusted automatically. @@ -462,47 +598,59 @@ public final class CropView: UIView, UIScrollViewDelegate { cropOutsideOverlay = view view.isUserInteractionEnabled = false - // TODO: Unstable operation. - insertSubview(view, aboveSubview: scrollView) + guideOutsideContainerView.addSubview(view) guideView.setCropOutsideOverlay(view) setNeedsLayout() layoutIfNeeded() } - + public func setLoadingOverlay(factory: (() -> UIView)?) { _pixeleditor_ensureMainThread() loadingOverlayFactory = factory } - + } // MARK: Internal extension CropView { private func setImage(_ cgImage: CGImage) { - imageView.image = UIImage(cgImage: cgImage, scale: 1, orientation: .up) + imagePlatterView.image = UIImage( + cgImage: cgImage, + scale: 1, + orientation: .up + ) } - + override public func layoutSubviews() { super.layoutSubviews() - - if let outOfBoundsOverlay = cropOutsideOverlay { - // TODO: Get an optimized size - outOfBoundsOverlay.frame.size = .init(width: UIScreen.main.bounds.width * 1.5, height: UIScreen.main.bounds.height * 1.5) - outOfBoundsOverlay.center = center + + // TODO: Get an optimized size + guideOutsideContainerView.frame.size = .init( + width: UIScreen.main.bounds.width * 1.5, + height: UIScreen.main.bounds.height * 1.5 + ) + guideOutsideContainerView.center = center + + if let cropOutsideOverlay { + cropOutsideOverlay.frame = guideOutsideContainerView.bounds } - + /// to update masking with cropOutsideOverlay guideView.setNeedsLayout() - + store.commit { if $0.frame != frame { $0.frame = frame } } - + + #if DEBUG + scrollPlatterView.layer.addSublayer(_debug_shapeLayer) + #endif + } private func updateScrollContainerView( @@ -512,58 +660,119 @@ extension CropView { animatesRotation: Bool ) { func perform() { + frame: do { - let bounds = self.bounds.inset(by: contentInset) - - let size: CGSize - let aspectRatio = PixelAspectRatio(crop.cropExtent.size) - switch crop.rotation { - case .angle_0: - size = aspectRatio.sizeThatFitsWithRounding(in: bounds.size) - guideView.setLockedAspectRatio(preferredAspectRatio) - case .angle_90: - size = aspectRatio.swapped().sizeThatFitsWithRounding(in: bounds.size) - guideView.setLockedAspectRatio(preferredAspectRatio?.swapped()) - case .angle_180: - size = aspectRatio.sizeThatFitsWithRounding(in: bounds.size) - guideView.setLockedAspectRatio(preferredAspectRatio) - case .angle_270: - size = aspectRatio.swapped().sizeThatFitsWithRounding(in: bounds.size) - guideView.setLockedAspectRatio(preferredAspectRatio?.swapped()) + + let contentRect: CGRect = { + + let bounds = self.bounds.inset(by: contentInset) + + let size = PixelAspectRatio(crop.cropExtent.size) + .sizeThatFitsWithRounding(in: bounds.size) + + return .init( + origin: .init( + x: contentInset.left + ((bounds.width - size.width) / 2) /* centering offset */, + y: contentInset.top + ((bounds.height - size.height) / 2) /* centering offset */ + ), + size: size + ) + }() + + let length: CGFloat = 1600 + let scrollViewFrame = CGRect( + origin: .zero, + size: .init(width: length, height: length) + ) + + if clipsToGuide { + scrollPlatterView.bounds.size = contentRect.size + scrollPlatterView.clipsToBounds = true + } else { + scrollPlatterView.bounds.size = scrollViewFrame.size + scrollPlatterView.clipsToBounds = false } - scrollView.transform = crop.rotation.transform - - scrollView.frame = .init( - origin: .init( - x: contentInset.left + ((bounds.width - size.width) / 2) /* centering offset */, - y: contentInset.top + ((bounds.height - size.height) / 2) /* centering offset */ - ), - size: size + scrollPlatterView.center = .init(x: self.bounds.midX, y: self.bounds.midY) + + scrollView.bounds.size = scrollViewFrame.size + scrollView.center = CGPoint( + x: scrollPlatterView.bounds.midX, + y: scrollPlatterView.bounds.midY ) - - scrollBackdropView.frame = scrollView.frame - } - applyLayoutDescendants: do { - guideView.frame = scrollView.frame - } - - zoom: do { - - let (min, max) = crop.calculateZoomScale(scrollViewSize: scrollView.bounds.size) - - scrollView.minimumZoomScale = min - scrollView.maximumZoomScale = max - - scrollView.contentInset = .zero - scrollView.zoom(to: crop.cropExtent, animated: false) - // WORKAROUND: - // Fixes `zoom to rect` does not apply the correct state when restoring the state from first-time displaying view. - scrollView.zoom(to: crop.cropExtent, animated: false) + scrollBackdropView.bounds.size = scrollViewFrame.size + scrollBackdropView.center = CGPoint( + x: scrollPlatterView.bounds.midX, + y: scrollPlatterView.bounds.midY + ) + + guideMaximumView.frame = contentRect + guideBackdropView.frame = contentRect + + guideShadowingView.frame = { + + let bounds = self.bounds.inset(by: contentInset) + + let size = PixelAspectRatio(crop.cropExtent.size) + .sizeThatFitsWithRounding(in: bounds.size) + + return .init( + origin: .init( + x: ((contentInset.left + contentInset.right) / 2) + + ((bounds.width - size.width) / 2) /* centering offset */, + y: ((contentInset.top + contentInset.bottom) / 2) + + ((bounds.height - size.height) / 2) /* centering offset */ + ), + size: size + ) + }() + + guideView.frame = contentRect + + scrollView.transform = CGAffineTransform(rotationAngle: crop.aggregatedRotation.radians) + + updateScrollViewInset(crop: crop) + + // zoom + do { + + let (min, max) = crop.calculateZoomScale( + visibleSize: guideView.bounds + .applying(CGAffineTransform(rotationAngle: crop.aggregatedRotation.radians)) + .size + ) + + scrollView.minimumZoomScale = min + scrollView.maximumZoomScale = max + + imagePlatterView.frame.origin = .zero + + func _zoom() { + + scrollView.customZoom( + to: crop.zoomExtent(visibleSize: guideView.bounds.size), + guideSize: guideView.bounds.size, + adjustmentRotation: crop.aggregatedRotation.radians, + animated: false + ) + + if isZoomEnabled == false { + let scale = scrollView.zoomScale + scrollView.minimumZoomScale = scale + scrollView.maximumZoomScale = scale + } + + } + + _zoom() + + } + } + } - + if animated { layoutIfNeeded() @@ -607,73 +816,80 @@ extension CropView { @inline(__always) private func willChangeGuideView() { - debounce.on { /* for debounce */ } + // flush scheduled debouncing + debounce.on { /* for debounce */ } } - @inline(__always) - private func didChangeGuideViewWithDelay() { - - func applyCropRotation(rotation: EditingCrop.Rotation, insets: UIEdgeInsets) -> UIEdgeInsets { - switch rotation { - case .angle_0: - return insets - case .angle_90: - return .init( - top: insets.left, - left: insets.bottom, - bottom: insets.right, - right: insets.top - ) - case .angle_180: - return .init( - top: insets.bottom, - left: insets.right, - bottom: insets.top, - right: insets.left + private func makeScrollViewInset(aggregatedRotaion: CGFloat) -> UIEdgeInsets { + + let o: CGPoint = { + + let base = + guideBackdropView + .convert( + guideBackdropView.bounds, + to: scrollBackdropView ) - case .angle_270: - return .init( - top: insets.right, - left: insets.top, - bottom: insets.left, - right: insets.bottom + + let actualRect = + guideView + .convert( + guideView.bounds, + to: scrollBackdropView ) - } - } - - guard let currentProposedCrop = store.state.proposedCrop else { - return - } - - let visibleRect = guideView.convert(guideView.bounds, to: imageView) - - updateContentInset: do { - let rect = self.guideView.convert(self.guideView.bounds, to: scrollBackdropView) - - let bounds = scrollBackdropView.bounds - let insets = UIEdgeInsets.init( - top: rect.minY, - left: rect.minX, - bottom: bounds.maxY - rect.maxY, - right: bounds.maxX - rect.maxX + + return CGPoint( + x: base.midX - actualRect.midX, + y: base.midY - actualRect.midY ) - - let resolvedInsets = applyCropRotation(rotation: currentProposedCrop.rotation, insets: insets) - - scrollView.contentInset = resolvedInsets - } - - EditorLog.debug("[CropView] visbleRect : \(visibleRect), guideViewFrame: \(guideView.frame)") - - store.commit { - // 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 = $0.preferredAspectRatio - $0.proposedCrop?.updateCropExtentNormalizing( - visibleRect, - respectingAspectRatio: preferredAspectRatio + + }() + + let anchorOffset = CGPoint( + x: (guideView.bounds.width) / 2 + o.x, + y: (guideView.bounds.height) / 2 + o.y + ) + + let actualRect = + guideView + .convert( + guideView.bounds.applying( + CGAffineTransform(translationX: -anchorOffset.x, y: -anchorOffset.y) + .concatenating(.init(rotationAngle: -aggregatedRotaion)) + .concatenating(.init(translationX: anchorOffset.x, y: anchorOffset.y)) + ), + to: scrollBackdropView ) + + let bounds = scrollBackdropView.bounds + + let insetsForActual = UIEdgeInsets.init( + top: actualRect.minY, + left: actualRect.minX, + bottom: bounds.maxY - actualRect.maxY, + right: bounds.maxX - actualRect.maxX + ) + + return insetsForActual + } + + private func updateScrollViewInset(crop: EditingCrop) { + scrollView.contentInset = makeScrollViewInset( + aggregatedRotaion: crop.aggregatedRotation.radians + ) + } + + @inline(__always) + private func didChangeGuideViewWithDelay() { + + guard let currentProposedCrop = store.state.proposedCrop else { + return } - + + record() + + updateScrollViewInset(crop: currentProposedCrop) + /// Triggers layout update later debounce.on { [weak self] in @@ -685,66 +901,119 @@ extension CropView { } } - @inline(__always) - private func didChangeScrollView() { - store.commit { - let rect = guideView.convert(guideView.bounds, to: imageView) + private func record() { + store.commit { state in + + let crop = state.proposedCrop! + + // remove rotation while converting rect + let current = scrollView.transform + let currentGuideViewCenter = guideView.center + + do { + // rotating support + let croppingRect = guideView.convert(guideView.bounds, to: guideBackdropView) + + // offsets guide view rect in maximum size + // for case of adjusted guide view by interaction + let offsetX = croppingRect.midX - guideBackdropView.bounds.midX + let offsetY = croppingRect.midY - guideBackdropView.bounds.midY + + // move focusing area to center + scrollView.transform = CGAffineTransform(rotationAngle: crop.aggregatedRotation.radians) + .concatenating(.init(translationX: -offsetX, y: -offsetY)) + .concatenating(.init(rotationAngle: -crop.aggregatedRotation.radians)) + + // TODO: Find calculation way withoug using convert rect + // To work correctly, ignoring transform temporarily. + + // move the guide view to center for convert-rect. + guideView.center = guideBackdropView.center + } + + // calculate + let guideRectInImageView = guideView.convert(guideView.bounds, to: imagePlatterView) + + do { + // restore guide view center same as displaying + guideView.center = currentGuideViewCenter + + // restore rotation + scrollView.transform = current + } + + // make crop extent for image + // converts rectangle for display into image's geometry. + let resolvedRect = crop.makeCropExtent( + rect: guideRectInImageView + ) + // 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 = $0.preferredAspectRatio - $0.proposedCrop?.updateCropExtentNormalizing( - rect, + let preferredAspectRatio = state.preferredAspectRatio + state.proposedCrop?.updateCropExtent( + resolvedRect, respectingAspectRatio: preferredAspectRatio ) } } + @inline(__always) + private func didChangeScrollView() { + record() + } + // MARK: UIScrollViewDelegate public func viewForZooming(in scrollView: UIScrollView) -> UIView? { - return imageView + return imagePlatterView } public func scrollViewDidZoom(_ scrollView: UIScrollView) { - func adjustFrameToCenterOnZooming() { - var frameToCenter = imageView.frame - // center horizontally - if frameToCenter.size.width < scrollView.bounds.width { - frameToCenter.origin.x = (scrollView.bounds.width - frameToCenter.size.width) / 2 - } else { - frameToCenter.origin.x = 0 - } + // TODO: consider if we need this. + // adjustFrameToCenterOnZooming + // do { + // var frameToCenter = imageView.frame + // + // // center horizontally + // if frameToCenter.size.width < scrollView.bounds.width { + // frameToCenter.origin.x = (scrollView.bounds.width - frameToCenter.size.width) / 2 + // } else { + // frameToCenter.origin.x = 0 + // } + // + // // center vertically + // if frameToCenter.size.height < scrollView.bounds.height { + // frameToCenter.origin.y = (scrollView.bounds.height - frameToCenter.size.height) / 2 + // } else { + // frameToCenter.origin.y = 0 + // } + // + // imageView.frame = frameToCenter + // } - // center vertically - if frameToCenter.size.height < scrollView.bounds.height { - frameToCenter.origin.y = (scrollView.bounds.height - frameToCenter.size.height) / 2 - } else { - frameToCenter.origin.y = 0 - } - - imageView.frame = frameToCenter - } - - adjustFrameToCenterOnZooming() - debounce.on { [weak self] in - + guard let self = self else { return } - + self.store.commit { $0.layoutVersion += 1 } } } - + public func scrollViewDidScroll(_ scrollView: UIScrollView) { - + debounce.on { [weak self] in - - guard let self = self else { return } - guard self.scrollView.isTracking == false else { return } - + guard let self = self else { + return + } + + guard self.scrollView.isTracking == false else { + return + } + self.store.commit { $0.layoutVersion += 1 } @@ -759,7 +1028,8 @@ extension CropView { guideView.willBeginScrollViewAdjustment() } - public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) + { if !decelerate { didChangeScrollView() guideView.didEndScrollViewAdjustment() @@ -779,4 +1049,264 @@ extension CropView { didChangeScrollView() guideView.didEndScrollViewAdjustment() } + + var remainingScroll: UIEdgeInsets { + + guard let crop = store.state.proposedCrop else { + return .zero + } + + let sourceInsets: UIEdgeInsets = { + + let guideViewRectInPlatter = guideView.convert(guideView.bounds, to: imagePlatterView) + + let scale = Geometry.diagonalRatio(to: guideView.bounds.size, from: guideViewRectInPlatter.size) + + print(scale) + + let outbound = imagePlatterView.bounds + + let value = UIEdgeInsets( + top: guideViewRectInPlatter.minY - outbound.minY, + left: guideViewRectInPlatter.minX - outbound.minX, + bottom: outbound.maxY - guideViewRectInPlatter.maxY, + right: outbound.maxX - guideViewRectInPlatter.maxX + ) + +#if false + + let maxRectInPlatter = imagePlatterView.convert( + guideViewRectInPlatter.inset(by: value.inversed()), + to: imagePlatterView + ) + + let path = UIBezierPath() + path.append(.init(rect: guideViewRectInPlatter)) + path.append(.init(rect: maxRectInPlatter)) + + imagePlatterView._debug_setPath(path: path) + +#endif + + return value.multiplied(scale) + + }() + + var patternAngleDegree = crop.aggregatedRotation.degrees.truncatingRemainder(dividingBy: 360) + if patternAngleDegree > 0 { + patternAngleDegree -= 360 + } + + print(patternAngleDegree) + + var resolvedInsets: UIEdgeInsets { + switch patternAngleDegree { + + case 0: + return sourceInsets + case -90: + + return .init( + top: sourceInsets.right, + left: sourceInsets.top, + bottom: sourceInsets.left, + right: sourceInsets.bottom + ) + + case -180: + + return .init( + top: sourceInsets.bottom, + left: sourceInsets.right, + bottom: sourceInsets.top, + right: sourceInsets.left + ) + + case -270: + + return .init( + top: sourceInsets.left, + left: sourceInsets.bottom, + bottom: sourceInsets.right, + right: sourceInsets.top + ) + + case -90..<0: + + return .init( + top: min(sourceInsets.top, sourceInsets.right), + left: min(sourceInsets.top, sourceInsets.left), + bottom: min(sourceInsets.bottom, sourceInsets.left), + right: min(sourceInsets.bottom, sourceInsets.right) + ) + + case -180..<(-90): + + return .init( + top: min(sourceInsets.bottom, sourceInsets.right), + left: min(sourceInsets.top, sourceInsets.right), + bottom: min(sourceInsets.top, sourceInsets.left), + right: min(sourceInsets.bottom, sourceInsets.left) + ) + + case -270..<(-180): + + return .init( + top: min(sourceInsets.bottom, sourceInsets.left), + left: min(sourceInsets.bottom, sourceInsets.right), + bottom: min(sourceInsets.top, sourceInsets.right), + right: min(sourceInsets.top, sourceInsets.left) + ) + + case -360..<(-270): + + return .init( + top: min(sourceInsets.top, sourceInsets.left), + left: min(sourceInsets.bottom, sourceInsets.left), + bottom: min(sourceInsets.bottom, sourceInsets.right), + right: min(sourceInsets.top, sourceInsets.right) + ) + + default: + return sourceInsets + } + + } + + return resolvedInsets + + } +} + +extension UIEdgeInsets { + fileprivate func inversed() -> Self { + .init( + top: -top, + left: -left, + bottom: -bottom, + right: -right + ) + } + + fileprivate func multiplied(_ value: CGFloat) -> Self { + .init( + top: top * value, + left: left * value, + bottom: bottom * value, + right: right * value + ) + } + + fileprivate func minZero() -> Self { + .init( + top: max(0, top), + left: max(0, left), + bottom: max(0, bottom), + right: max(0, right) + ) + } +} + +extension CGRect { + + /// Return a rect rotated around center + fileprivate func rotated(_ radians: Double) -> CGRect { + + let rotated = self.applying(.init(rotationAngle: radians)) + + return .init( + x: self.minX - (rotated.width - self.width) / 2, + y: self.minY - (rotated.height - self.height) / 2, + width: rotated.width, + height: rotated.height + ) + } + +} + +extension UIScrollView { + + fileprivate var maxContentOffset: CGPoint { + CGPoint( + x: contentSize.width - bounds.width + contentInset.right, + y: contentSize.height - bounds.height + contentInset.bottom + ) + } + + fileprivate var minContentOffset: CGPoint { + CGPoint( + x: -contentInset.left, + y: -contentInset.top + ) + } + + fileprivate func customZoom( + to rect: CGRect, + guideSize: CGSize, + adjustmentRotation: CGFloat, + animated: Bool + ) { + + func run() { + + let targetContentSize = rect.size + let boundSize = guideSize + + let minXScale = boundSize.width / targetContentSize.width + let minYScale = boundSize.height / targetContentSize.height + let targetScale = min(minXScale, minYScale) + + setZoomScale(targetScale, animated: false) + + var targetContentOffset = + rect + .rotated(adjustmentRotation) + .applying(.init(scaleX: targetScale, y: targetScale)) + .origin + + targetContentOffset.x -= contentInset.left + targetContentOffset.y -= contentInset.top + + let maxContentOffset = self.maxContentOffset + + let minContentOffset = self.minContentOffset + + targetContentOffset.x = min( + max(targetContentOffset.x, minContentOffset.x), + maxContentOffset.x + ) + targetContentOffset.y = min( + max(targetContentOffset.y, minContentOffset.y), + maxContentOffset.y + ) + + setContentOffset(targetContentOffset, animated: false) + + #if DEBUG + print( + """ + [Zoom] + input: \(rect), + bound: \(boundSize), + targetScale: \(targetScale), + targetContentOffset: \(targetContentOffset), + minContentOffset: \(minContentOffset) + maxContentOffset: \(maxContentOffset) + """ + ) + #endif + } + + if animated { + let animator = UIViewPropertyAnimator(duration: 0.6, dampingRatio: 1) + animator.addAnimations { + run() + } + animator.startAnimation() + } else { + run() + } + + } + } diff --git a/Sources/BrightroomUI/Shared/Components/Crop/SwiftUICropView.swift b/Sources/BrightroomUI/Shared/Components/Crop/SwiftUICropView.swift index c0a6e72a..a250d8b1 100644 --- a/Sources/BrightroomUI/Shared/Components/Crop/SwiftUICropView.swift +++ b/Sources/BrightroomUI/Shared/Components/Crop/SwiftUICropView.swift @@ -27,7 +27,7 @@ import BrightroomEngine public final class _PixelEditor_WrapperViewController: UIViewController { - private let bodyView: BodyView + let bodyView: BodyView init(bodyView: BodyView) { self.bodyView = bodyView @@ -56,22 +56,24 @@ public struct SwiftUICropView: UIViewControllerRepresentable { public typealias UIViewControllerType = _PixelEditor_WrapperViewController private let cropInsideOverlay: AnyView? - - private let factory: () -> CropView - + private let editingStack: EditingStack + + private var _rotation: EditingCrop.Rotation? + private var _adjustmentAngle: EditingCrop.AdjustmentAngle? + private var _croppingAspectRatio: PixelAspectRatio? + public init( editingStack: EditingStack, cropInsideOverlay: AnyView? = nil ) { self.cropInsideOverlay = cropInsideOverlay - - self.factory = { - CropView(editingStack: editingStack) - } + self.editingStack = editingStack } public func makeUIViewController(context: Context) -> _PixelEditor_WrapperViewController { - let view = factory() + + let view = CropView(editingStack: editingStack) + view.isAutoApplyEditingStackEnabled = true let controller = _PixelEditor_WrapperViewController.init(bodyView: view) @@ -94,6 +96,38 @@ public struct SwiftUICropView: UIViewControllerRepresentable { public func updateUIViewController(_ uiViewController: _PixelEditor_WrapperViewController, context: Context) { + if let _rotation { + uiViewController.bodyView.setRotation(_rotation) + } + + if let _adjustmentAngle { + uiViewController.bodyView.setAdjustmentAngle(_adjustmentAngle) + } + + uiViewController.bodyView.setCroppingAspectRatio(_croppingAspectRatio) } - + + public func rotation(_ rotation: EditingCrop.Rotation?) -> Self { + + var modified = self + modified._rotation = rotation + return modified + } + + public func adjustmentAngle(_ angle: EditingCrop.AdjustmentAngle?) -> Self { + + var modified = self + modified._adjustmentAngle = angle + return modified + + } + + public func croppingAspectRatio(_ rect: PixelAspectRatio?) -> Self { + + var modified = self + modified._croppingAspectRatio = rect + return modified + + } + } diff --git a/Sources/BrightroomUI/Shared/Components/Drawing/BlurryMaskingView.swift b/Sources/BrightroomUI/Shared/Components/Drawing/BlurryMaskingView.swift index 75aec2fb..04b50515 100644 --- a/Sources/BrightroomUI/Shared/Components/Drawing/BlurryMaskingView.swift +++ b/Sources/BrightroomUI/Shared/Components/Drawing/BlurryMaskingView.swift @@ -27,59 +27,30 @@ import BrightroomEngine import Verge public final class BlurryMaskingView: PixelEditorCodeBasedView, UIScrollViewDelegate { + private struct State: Equatable { - fileprivate(set) var frame: CGRect = .zero fileprivate(set) var bounds: CGRect = .zero - - fileprivate var hasLoaded = false - + fileprivate(set) var proposedCrop: EditingCrop? fileprivate(set) var brushSize: CanvasView.BrushSize = .point(30) - - fileprivate let contentInset: UIEdgeInsets = .zero - - func scrollViewFrame() -> CGRect? { - + + func brushPixelSize() -> CGFloat? { + guard let proposedCrop = proposedCrop else { return nil } - - let bounds = self.bounds.inset(by: contentInset) - - let size: CGSize + let aspectRatio = PixelAspectRatio(proposedCrop.cropExtent.size) - switch proposedCrop.rotation { - case .angle_0: - size = aspectRatio.sizeThatFitsWithRounding(in: bounds.size) - case .angle_90: - size = aspectRatio.swapped().sizeThatFitsWithRounding(in: bounds.size) - case .angle_180: - size = aspectRatio.sizeThatFitsWithRounding(in: bounds.size) - case .angle_270: - size = aspectRatio.swapped().sizeThatFitsWithRounding(in: bounds.size) - } - - return .init( - origin: .init( - x: contentInset.left + ((bounds.width - size.width) / 2) /* centering offset */, - y: contentInset.top + ((bounds.height - size.height) / 2) /* centering offset */ - ), - size: size - ) - } - - func brushPixelSize() -> CGFloat? { - - guard let proposedCrop = proposedCrop, let size = scrollViewFrame()?.size else { - return nil - } - - let (min, _) = proposedCrop.calculateZoomScale(scrollViewSize: size) + let size = aspectRatio.sizeThatFitsWithRounding(in: bounds.size) + let (min, _) = proposedCrop.calculateZoomScale(visibleSize: size) + + let scale = proposedCrop.scaleForDrawing() + switch brushSize { case let .point(points): - return points / min + return points / scale / min case let .pixel(pixels): return pixels } @@ -96,10 +67,10 @@ public final class BlurryMaskingView: PixelEditorCodeBasedView, UIScrollViewDele public var isBackdropImageViewHidden: Bool { get { - backdropImageView.isHidden + backingView.isImageViewHidden } set { - backdropImageView.isHidden = newValue + backingView.isImageViewHidden = newValue } } @@ -111,13 +82,11 @@ public final class BlurryMaskingView: PixelEditorCodeBasedView, UIScrollViewDele blurryImageView.isHidden = newValue } } - - private let scrollView = CropView._CropScrollView() - + + let backingView: CropView + private let containerView = ContainerView() - - private let backdropImageView = _ImageView() - + private let blurryImageView = _ImageView() private let drawingView = SmoothPathDrawingView() @@ -127,9 +96,7 @@ public final class BlurryMaskingView: PixelEditorCodeBasedView, UIScrollViewDele private var subscriptions = Set() private let editingStack: EditingStack - - private var hasSetupScrollViewCompleted = false - + private let store: UIStateStore private var currentBrush: OvalBrush? @@ -144,6 +111,12 @@ public final class BlurryMaskingView: PixelEditorCodeBasedView, UIScrollViewDele public init(editingStack: EditingStack) { self.editingStack = editingStack + self.backingView = .init( + editingStack: editingStack, + contentInset: .zero + ) + self.backingView.accessibilityIdentifier = "BlurryMasking" + store = .init( initialState: .init(), logger: nil @@ -154,23 +127,20 @@ public final class BlurryMaskingView: PixelEditorCodeBasedView, UIScrollViewDele setUp: do { backgroundColor = .clear - addSubview(scrollView) - - scrollView.clipsToBounds = true - scrollView.delegate = self - scrollView.isScrollEnabled = false - - scrollView.addSubview(containerView) - - containerView.addContent(backdropImageView) + addSubview(backingView) + backingView.isGuideInteractionEnabled = false + backingView.clipsToGuide = true + backingView.setCropOutsideOverlay(nil) + backingView.setCropInsideOverlay(nil) + backingView.setOverlayInImageView(containerView) + backingView.isScrollEnabled = false + backingView.isZoomEnabled = false + backingView.isAutoApplyEditingStackEnabled = false + containerView.addContent(blurryImageView) containerView.addContent(canvasView) containerView.addContent(drawingView) - backdropImageView.accessibilityIdentifier = "backdropImageView" - backdropImageView.isUserInteractionEnabled = false - backdropImageView.contentMode = .scaleAspectFit - blurryImageView.accessibilityIdentifier = "blurryImageView" blurryImageView.isUserInteractionEnabled = false blurryImageView.contentMode = .scaleAspectFit @@ -188,7 +158,7 @@ public final class BlurryMaskingView: PixelEditorCodeBasedView, UIScrollViewDele } currentBrush = .init(color: .black, pixelSize: pixelSize) - + let drawnPath = DrawnPath(brush: currentBrush!, path: path) canvasView.previewDrawnPath = drawnPath } @@ -214,9 +184,17 @@ public final class BlurryMaskingView: PixelEditorCodeBasedView, UIScrollViewDele guard let self = self else { return } if let state = state.mapIfPresent(\.loadedState) { - - state.ifChanged(\.currentEdit.crop) { cropRect in - + + state.ifChanged(\.currentEdit.crop).do { cropRect in + + // scaling for drawing paths + [self.canvasView, self.drawingView].forEach { view in + view.bounds = .init(origin: .zero, size: cropRect.imageSize) + let scale = Geometry.diagonalRatio(to: cropRect.scrollViewContentSize(), from: cropRect.imageSize) + view.transform = .init(scaleX: scale, y: scale) + view.frame.origin = .zero + } + /** To avoid running pending layout operations from User Initiated actions. */ @@ -233,11 +211,11 @@ public final class BlurryMaskingView: PixelEditorCodeBasedView, UIScrollViewDele defaultAppearance: do { setLoadingOverlay(factory: { - LoadingBlurryOverlayView(effect: UIBlurEffect(style: .dark), activityIndicatorStyle: .whiteLarge) + LoadingBlurryOverlayView(effect: UIBlurEffect(style: .dark), activityIndicatorStyle: .large) }) } } - + override public func willMove(toSuperview newSuperview: UIView?) { super.willMove(toSuperview: newSuperview) @@ -249,66 +227,18 @@ public final class BlurryMaskingView: PixelEditorCodeBasedView, UIScrollViewDele editingStack.start() binding: do { - store.sinkState(queue: .mainIsolated()) { [weak self] state in - - guard let self = self else { return } - - state.ifChanged(\.frame, \.proposedCrop) { frame, crop in - - guard let crop = crop else { return } - - guard frame != .zero else { return } - - setupScrollViewOnce: do { - if self.hasSetupScrollViewCompleted == false { - self.hasSetupScrollViewCompleted = true - - let scrollView = self.scrollView - - self.containerView.bounds = .init( - origin: .zero, - size: crop.scrollViewContentSize() - ) - - // Do we need this? it seems ImageView's bounds changes contentSize automatically. not sure. - UIView.performWithoutAnimation { - let currentZoomScale = scrollView.zoomScale - let contentSize = crop.scrollViewContentSize() - if scrollView.contentSize != contentSize { - scrollView.contentInset = .zero - scrollView.zoomScale = 1 - scrollView.contentSize = contentSize - scrollView.zoomScale = currentZoomScale - } - } - } - } - - self.updateScrollContainerView( - by: crop, - animated: state.hasLoaded, - animatesRotation: state.hasChanges(\.proposedCrop?.rotation) - ) - } - } - .store(in: &subscriptions) - + editingStack.sinkState { [weak self] (state: Changes) in - guard let self = self else { return } - - state.ifChanged(\.isLoading) { isLoading in - self.updateLoadingOverlay(displays: isLoading) - } + guard let self = self else { return } if let state = state.mapIfPresent(\.loadedState) { - state.ifChanged(\.editingPreviewImage) { image in - self.backdropImageView.display(image: image) + state.ifChanged(\.editingPreviewImage).do { image in self.blurryImageView.display(image: BlurredMask.blur(image: image)) } - state.ifChanged(\.currentEdit.drawings.blurredMaskPaths) { paths in + state.ifChanged(\.currentEdit.drawings.blurredMaskPaths).do { paths in self.canvasView.setResolvedDrawnPaths(paths) } @@ -330,140 +260,46 @@ public final class BlurryMaskingView: PixelEditorCodeBasedView, UIScrollViewDele $0.brushSize = size } } - - private func updateLoadingOverlay(displays: Bool) { - - if displays, let factory = self.loadingOverlayFactory { - - scrollView.isHidden = true - - let loadingOverlay = factory() - self.currentLoadingOverlay = loadingOverlay - self.addSubview(loadingOverlay) - AutoLayoutTools.setEdge(loadingOverlay, self) - - loadingOverlay.alpha = 0 - UIViewPropertyAnimator(duration: 0.6, dampingRatio: 1) { - loadingOverlay.alpha = 1 - } - .startAnimation() - - } else { - - scrollView.isHidden = false - - if let view = currentLoadingOverlay { - UIViewPropertyAnimator(duration: 0.6, dampingRatio: 1) { - view.alpha = 0 - }&>.do { - $0.addCompletion { _ in - view.removeFromSuperview() - } - $0.startAnimation() - } - } - - } - - } - - - private func updateScrollContainerView( - by crop: EditingCrop, - animated: Bool, - animatesRotation: Bool - ) { - - func perform() { - - guard let scrollViewFrame = store.state.primitive.scrollViewFrame() else { - return - } - - frame: do { - scrollView.transform = crop.rotation.transform - scrollView.frame = scrollViewFrame - } - - zoom: do { - let (min, max) = crop.calculateZoomScale(scrollViewSize: scrollView.bounds.size) - - scrollView.minimumZoomScale = min - scrollView.maximumZoomScale = max - - scrollView.contentInset = .zero - scrollView.zoom(to: crop.cropExtent, animated: false) - // WORKAROUND: - // Fixes `zoom to rect` does not apply the correct state when restoring the state from first-time displaying view. - scrollView.zoom(to: crop.cropExtent, animated: false) - - disableZooming: do { - let zoomedScale = scrollView.zoomScale - scrollView.minimumZoomScale = zoomedScale - scrollView.maximumZoomScale = zoomedScale - } - } - } - - if animated { - layoutIfNeeded() - - UIViewPropertyAnimator(duration: 0.6, dampingRatio: 1) { [self] in - perform() - layoutIfNeeded() - }&>.do { - $0.startAnimation() - } - - } else { - UIView.performWithoutAnimation { - layoutIfNeeded() - perform() - } - } - } - + override public func layoutSubviews() { super.layoutSubviews() - + + backingView.frame = bounds + store.commit { - if $0.frame != frame { - $0.frame = frame - } if $0.bounds != bounds { $0.bounds = bounds } } } - - // MARK: UIScrollViewDelegate - - public func viewForZooming(in scrollView: UIScrollView) -> UIView? { - return containerView +} + +import SwiftUI + +public struct SwiftUIBlurryMaskingView: UIViewControllerRepresentable { + + public typealias UIViewControllerType = _PixelEditor_WrapperViewController + + private let editingStack: EditingStack + + public init( + editingStack: EditingStack + ) { + self.editingStack = editingStack } - public func scrollViewDidZoom(_ scrollView: UIScrollView) { - func adjustFrameToCenterOnZooming() { - var frameToCenter = containerView.frame - - // center horizontally - if frameToCenter.size.width < scrollView.bounds.width { - frameToCenter.origin.x = (scrollView.bounds.width - frameToCenter.size.width) / 2 - } else { - frameToCenter.origin.x = 0 - } - - // center vertically - if frameToCenter.size.height < scrollView.bounds.height { - frameToCenter.origin.y = (scrollView.bounds.height - frameToCenter.size.height) / 2 - } else { - frameToCenter.origin.y = 0 - } - - containerView.frame = frameToCenter - } - - adjustFrameToCenterOnZooming() + public func makeUIViewController(context: Context) -> _PixelEditor_WrapperViewController { + + let view = BlurryMaskingView(editingStack: editingStack) + + let controller = _PixelEditor_WrapperViewController.init(bodyView: view) + + return controller } + + public func updateUIViewController(_ uiViewController: _PixelEditor_WrapperViewController, context: Context) { + + } + } diff --git a/Sources/BrightroomUI/Shared/Components/Drawing/CanvasView.swift b/Sources/BrightroomUI/Shared/Components/Drawing/CanvasView.swift index a90e1e3b..c27415b5 100644 --- a/Sources/BrightroomUI/Shared/Components/Drawing/CanvasView.swift +++ b/Sources/BrightroomUI/Shared/Components/Drawing/CanvasView.swift @@ -63,7 +63,7 @@ public final class CanvasView: PixelEditorCodeBasedView { guard let self = self else { return } - state.ifChanged(\.resolvedDrawnPaths) { paths in + state.ifChanged(\.resolvedDrawnPaths).do { paths in let layers = paths.map { path -> CAShapeLayer in let layer = Self.makeShapeLayer(for: path.brush) diff --git a/Sources/BrightroomUI/Shared/Utils/EditingCrop+.swift b/Sources/BrightroomUI/Shared/Utils/EditingCrop+.swift index 0a070703..cdea8dfd 100644 --- a/Sources/BrightroomUI/Shared/Utils/EditingCrop+.swift +++ b/Sources/BrightroomUI/Shared/Utils/EditingCrop+.swift @@ -15,13 +15,23 @@ import BrightroomEngine extension EditingCrop { func scrollViewContentSize() -> CGSize { - imageSize + // Use imageSize for masking view +// imageSize + PixelAspectRatio(imageSize).size(byWidth: 1000) } - - func calculateZoomScale(scrollViewSize: CGSize) -> (min: CGFloat, max: CGFloat) { - let minXScale = scrollViewSize.width / imageSize.width - let minYScale = scrollViewSize.height / imageSize.height + + func scaleForDrawing() -> CGFloat { + let scaleFromOriginal = Geometry.diagonalRatio(to: scrollViewContentSize(), from: imageSize) + + return scaleFromOriginal + } + + func calculateZoomScale(visibleSize: CGSize) -> (min: CGFloat, max: CGFloat) { + let contentSize = scrollViewContentSize() + let minXScale = visibleSize.width / contentSize.width + let minYScale = visibleSize.height / contentSize.height + /** max meaning scale aspect fill */ @@ -29,4 +39,27 @@ extension EditingCrop { return (min: minScale, max: .greatestFiniteMagnitude) } + + func zoomExtent(visibleSize: CGSize) -> CGRect { + + let contentSize = scrollViewContentSize() + let cropExtent = cropExtent + + let scaleFromOriginal = Geometry.diagonalRatio(to: contentSize, from: imageSize) + + let _cropExtent = cropExtent.applying(.init(scaleX: scaleFromOriginal, y: scaleFromOriginal)) + + return _cropExtent + } + + func makeCropExtent(rect: CGRect) -> CGRect { + + let contentSize = scrollViewContentSize() + let cropExtent = rect + + let scaleFromOriginal = Geometry.diagonalRatio(to: imageSize, from: contentSize) + + return cropExtent.applying(.init(scaleX: scaleFromOriginal, y: scaleFromOriginal)) + } + } diff --git a/submodules/muukii/Reveal-SDK b/submodules/muukii/Reveal-SDK new file mode 160000 index 00000000..76b02972 --- /dev/null +++ b/submodules/muukii/Reveal-SDK @@ -0,0 +1 @@ +Subproject commit 76b02972c18f14c717751f10bbc2d6b07dab85c7