diff --git a/BlurMaskView.swift b/BlurMaskView.swift new file mode 100644 index 0000000..38971d9 --- /dev/null +++ b/BlurMaskView.swift @@ -0,0 +1,36 @@ +// +// BlurMaskView.swift +// moody +// +// Created by bart Shin on 25/06/2021. +// + +import SwiftUI +import PencilKit + +struct BlurMaskView: UIViewRepresentable { + + @Binding var canvas: PKCanvasView + @Binding var markerWidth: CGFloat + + private var tool: PKInkingTool { + PKInkingTool( + .marker, color: .white, width: markerWidth) + } + + func makeUIView(context: Context) -> PKCanvasView { + canvas.drawingPolicy = .anyInput + canvas.tool = tool + canvas.backgroundColor = .clear + return canvas + } + + func updateUIView(_ uiView: PKCanvasView, context: Context) { + canvas.tool = tool + } + + init(canvas: Binding, markerWidth: Binding) { + _canvas = canvas + _markerWidth = markerWidth + } +} diff --git a/moody.xcodeproj/project.pbxproj b/moody.xcodeproj/project.pbxproj index a6c10a6..d1a69c0 100644 --- a/moody.xcodeproj/project.pbxproj +++ b/moody.xcodeproj/project.pbxproj @@ -7,6 +7,17 @@ objects = { /* Begin PBXBuildFile section */ + 89110C812680AB50002779AA /* FilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89110C802680AB50002779AA /* FilterView.swift */; }; + 89110C832680AD0E002779AA /* ImageEditFactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89110C822680AD0E002779AA /* ImageEditFactor.swift */; }; + 89110C862680BCA0002779AA /* EditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89110C852680BCA0002779AA /* EditView.swift */; }; + 89110C882680BCEB002779AA /* BottomNavigationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89110C872680BCEB002779AA /* BottomNavigationButton.swift */; }; + 89110C8B2680BD57002779AA /* BottomNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89110C8A2680BD57002779AA /* BottomNavigationBar.swift */; }; + 89110C8D2680C729002779AA /* ImageEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89110C8C2680C729002779AA /* ImageEditor.swift */; }; + 891DF6912684277A00A314B8 /* EditingImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891DF6902684277A00A314B8 /* EditingImage.swift */; }; + 891DF6932684283400A314B8 /* FeedBackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891DF6922684283400A314B8 /* FeedBackView.swift */; }; + 894A3B1E2686BE020091D5FE /* Publish.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894A3B1D2686BE020091D5FE /* Publish.swift */; }; + 895BFC3A2680829000EB783E /* TuningPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BFC392680829000EB783E /* TuningPanel.swift */; }; + 898C02CC2685BA78008A2B2C /* BlurMaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898C02CB2685BA78008A2B2C /* BlurMaskView.swift */; }; C19759F8267CCE0B006003CA /* moodyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19759F7267CCE0B006003CA /* moodyApp.swift */; }; C19759FA267CCE0B006003CA /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19759F9267CCE0B006003CA /* ContentView.swift */; }; C19759FC267CCE0D006003CA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C19759FB267CCE0D006003CA /* Assets.xcassets */; }; @@ -15,7 +26,7 @@ C1975A04267CCE0D006003CA /* moody.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C1975A02267CCE0D006003CA /* moody.xcdatamodeld */; }; C1975A0F267CCE0D006003CA /* moodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1975A0E267CCE0D006003CA /* moodyTests.swift */; }; C1975A1A267CCE0D006003CA /* moodyUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1975A19267CCE0D006003CA /* moodyUITests.swift */; }; - C1975A2B267CCE8E006003CA /* Home.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1975A2A267CCE8E006003CA /* Home.swift */; }; + C1975A2B267CCE8E006003CA /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1975A2A267CCE8E006003CA /* HomeView.swift */; }; C1975A2D267CCEAF006003CA /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1975A2C267CCEAF006003CA /* ImagePicker.swift */; }; C1975A2F267CCEC8006003CA /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1975A2E267CCEC8006003CA /* HomeViewModel.swift */; }; C1975A31267CCEE2006003CA /* FilteredImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1975A30267CCEE2006003CA /* FilteredImage.swift */; }; @@ -39,6 +50,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 89110C802680AB50002779AA /* FilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterView.swift; sourceTree = ""; }; + 89110C822680AD0E002779AA /* ImageEditFactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEditFactor.swift; sourceTree = ""; }; + 89110C852680BCA0002779AA /* EditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditView.swift; sourceTree = ""; }; + 89110C872680BCEB002779AA /* BottomNavigationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomNavigationButton.swift; sourceTree = ""; }; + 89110C8A2680BD57002779AA /* BottomNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomNavigationBar.swift; sourceTree = ""; }; + 89110C8C2680C729002779AA /* ImageEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEditor.swift; sourceTree = ""; }; + 891DF6902684277A00A314B8 /* EditingImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditingImage.swift; sourceTree = ""; }; + 891DF6922684283400A314B8 /* FeedBackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedBackView.swift; sourceTree = ""; }; + 894A3B1D2686BE020091D5FE /* Publish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publish.swift; sourceTree = ""; }; + 895BFC392680829000EB783E /* TuningPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuningPanel.swift; sourceTree = ""; }; + 898C02CB2685BA78008A2B2C /* BlurMaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurMaskView.swift; sourceTree = ""; }; C19759F4267CCE0A006003CA /* moody.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = moody.app; sourceTree = BUILT_PRODUCTS_DIR; }; C19759F7267CCE0B006003CA /* moodyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = moodyApp.swift; sourceTree = ""; }; C19759F9267CCE0B006003CA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -53,7 +75,7 @@ C1975A15267CCE0D006003CA /* moodyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = moodyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C1975A19267CCE0D006003CA /* moodyUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = moodyUITests.swift; sourceTree = ""; }; C1975A1B267CCE0D006003CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - C1975A2A267CCE8E006003CA /* Home.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Home.swift; sourceTree = ""; }; + C1975A2A267CCE8E006003CA /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; C1975A2C267CCEAF006003CA /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; C1975A2E267CCEC8006003CA /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; C1975A30267CCEE2006003CA /* FilteredImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilteredImage.swift; sourceTree = ""; }; @@ -84,9 +106,30 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 89110C842680AD30002779AA /* Helper */ = { + isa = PBXGroup; + children = ( + 89110C872680BCEB002779AA /* BottomNavigationButton.swift */, + 89110C822680AD0E002779AA /* ImageEditFactor.swift */, + 894A3B1D2686BE020091D5FE /* Publish.swift */, + ); + path = Helper; + sourceTree = ""; + }; + 89110C892680BD18002779AA /* Edit */ = { + isa = PBXGroup; + children = ( + 89110C852680BCA0002779AA /* EditView.swift */, + 891DF6902684277A00A314B8 /* EditingImage.swift */, + 895BFC392680829000EB783E /* TuningPanel.swift */, + ); + name = Edit; + sourceTree = ""; + }; C19759EB267CCE0A006003CA = { isa = PBXGroup; children = ( + 898C02CB2685BA78008A2B2C /* BlurMaskView.swift */, C19759F6267CCE0A006003CA /* moody */, C1975A0D267CCE0D006003CA /* moodyTests */, C1975A18267CCE0D006003CA /* moodyUITests */, @@ -111,6 +154,7 @@ C1975A28267CCE32006003CA /* ViewModel */, C1975A27267CCE2B006003CA /* Model */, C19759F7267CCE0B006003CA /* moodyApp.swift */, + 89110C842680AD30002779AA /* Helper */, C19759F9267CCE0B006003CA /* ContentView.swift */, C19759FB267CCE0D006003CA /* Assets.xcassets */, C1975A00267CCE0D006003CA /* Persistence.swift */, @@ -159,6 +203,7 @@ isa = PBXGroup; children = ( C1975A2E267CCEC8006003CA /* HomeViewModel.swift */, + 89110C8C2680C729002779AA /* ImageEditor.swift */, ); path = ViewModel; sourceTree = ""; @@ -166,7 +211,11 @@ C1975A29267CCE38006003CA /* View */ = { isa = PBXGroup; children = ( - C1975A2A267CCE8E006003CA /* Home.swift */, + 89110C892680BD18002779AA /* Edit */, + C1975A2A267CCE8E006003CA /* HomeView.swift */, + 891DF6922684283400A314B8 /* FeedBackView.swift */, + 89110C8A2680BD57002779AA /* BottomNavigationBar.swift */, + 89110C802680AB50002779AA /* FilterView.swift */, C1975A2C267CCEAF006003CA /* ImagePicker.swift */, ); name = View; @@ -302,12 +351,23 @@ buildActionMask = 2147483647; files = ( C1975A31267CCEE2006003CA /* FilteredImage.swift in Sources */, + 894A3B1E2686BE020091D5FE /* Publish.swift in Sources */, C1975A01267CCE0D006003CA /* Persistence.swift in Sources */, + 89110C882680BCEB002779AA /* BottomNavigationButton.swift in Sources */, + 898C02CC2685BA78008A2B2C /* BlurMaskView.swift in Sources */, + 89110C862680BCA0002779AA /* EditView.swift in Sources */, + 891DF6912684277A00A314B8 /* EditingImage.swift in Sources */, C19759FA267CCE0B006003CA /* ContentView.swift in Sources */, C1975A2F267CCEC8006003CA /* HomeViewModel.swift in Sources */, - C1975A2B267CCE8E006003CA /* Home.swift in Sources */, + 89110C832680AD0E002779AA /* ImageEditFactor.swift in Sources */, + C1975A2B267CCE8E006003CA /* HomeView.swift in Sources */, + 891DF6932684283400A314B8 /* FeedBackView.swift in Sources */, + 89110C8D2680C729002779AA /* ImageEditor.swift in Sources */, C1975A2D267CCEAF006003CA /* ImagePicker.swift in Sources */, C1975A04267CCE0D006003CA /* moody.xcdatamodeld in Sources */, + 895BFC3A2680829000EB783E /* TuningPanel.swift in Sources */, + 89110C812680AB50002779AA /* FilterView.swift in Sources */, + 89110C8B2680BD57002779AA /* BottomNavigationBar.swift in Sources */, C19759F8267CCE0B006003CA /* moodyApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -467,6 +527,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"moody/Preview Content\""; + DEVELOPMENT_TEAM = 96XCF965VW; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = moody/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -488,6 +549,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"moody/Preview Content\""; + DEVELOPMENT_TEAM = 96XCF965VW; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = moody/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; diff --git a/moody/Assets.xcassets/selfie_dummy.imageset/Contents.json b/moody/Assets.xcassets/selfie_dummy.imageset/Contents.json new file mode 100644 index 0000000..33342df --- /dev/null +++ b/moody/Assets.xcassets/selfie_dummy.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "selfie_dummy.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/moody/Assets.xcassets/selfie_dummy.imageset/selfie_dummy.png b/moody/Assets.xcassets/selfie_dummy.imageset/selfie_dummy.png new file mode 100644 index 0000000..8c2bcdf Binary files /dev/null and b/moody/Assets.xcassets/selfie_dummy.imageset/selfie_dummy.png differ diff --git a/moody/BottomNavigationBar.swift b/moody/BottomNavigationBar.swift new file mode 100644 index 0000000..143d6ce --- /dev/null +++ b/moody/BottomNavigationBar.swift @@ -0,0 +1,49 @@ +// +// BottomNavigationBar.swift +// moody +// +// Created by bart Shin on 21/06/2021. +// + +import SwiftUI + +struct BottomNavigationBar: View { + + @Binding var navigationTag: String? + + var body: some View { + HStack { + createNavigationLink(for: EditView(), image: Image(systemName: "slider.horizontal.below.rectangle")) + createNavigationLink(for: FilterView(), image: Image(systemName: "camera.filters")) + } + } + + private func createNavigationLink(for destination: D, image: Image) -> some View where D: View { + NavigationLink( + destination: destination, + tag: String(describing: D.self), + selection: $navigationTag) + { + drawButton(for: D.self, image: image) + .padding(.horizontal) + } + } + + private func drawButton(for destination: D.Type, image: Image) -> some View where D: View{ + Button(action: { + navigationTag = String(describing: D.self) + } ) { + image + .resizable() + .frame(width: Constant.bottomButtonSize.width, + height: Constant.bottomButtonSize.height) + .padding() + } + .buttonStyle(BottomNavigation()) + } + + struct Constant { + static let bottomButtonSize = CGSize(width: 50, height: 50) + } +} + diff --git a/moody/ContentView.swift b/moody/ContentView.swift index 55ac461..2a3c4de 100644 --- a/moody/ContentView.swift +++ b/moody/ContentView.swift @@ -11,16 +11,19 @@ import CoreData import SwiftUI struct ContentView: View { + let editor = ImageEditor() + var body: some View { NavigationView{ - Home() - .navigationBarTitle("Filter") + HomeView() .preferredColorScheme(.dark) } + .environmentObject(editor) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() + .environmentObject(ImageEditor.forPreview) } } diff --git a/moody/EditView.swift b/moody/EditView.swift new file mode 100644 index 0000000..851ccc8 --- /dev/null +++ b/moody/EditView.swift @@ -0,0 +1,99 @@ +// +// EditView.swift +// moody +// +// Created by bart Shin on 21/06/2021. +// + +import SwiftUI + +struct EditView: View, EditorDelegation { + + @EnvironmentObject var editor: ImageEditor + @State private var isShowingPicker = false + @State private var feedBackImage: Image? + @State private var currentControl: String = ImageColorControl.brightness.rawValue + + var body: some View { + ZStack { + VStack(spacing: 0) { + EditingImage(currentControl: $currentControl) + .contentShape(Rectangle()) + TuningPanel(currentControl: $currentControl) + .onChange(of: isShowingPicker, perform: resetTunner(_:)) + .disabled(editor.imageForDisplay == nil) + } + FeedBackView(feedBackImage: $feedBackImage) + } + .toolbar{ + ToolbarItem(placement: .navigationBarLeading, content: drawSaveButton) + ToolbarItem(placement: .navigationBarTrailing, + content: drawPickerButton) + } + .sheet(isPresented: $isShowingPicker, content: createImagePicker) + .onAppear (perform: showPickerIfNeeded) + .navigationTitle("Edit") + } + + private func drawSaveButton() -> some View { + Button(action: { + editor.saveImage() + }) { + Text("SAVE") + .font(.title) + } + } + + private func drawPickerButton() -> some View { + Button(action: { + isShowingPicker = true + }) { + Image(systemName: "photo") + .font(.title2) + } + } + + private func createImagePicker () -> ImagePicker { + ImagePicker( + picker: $isShowingPicker, + imageData: Binding.constant(Data()), + passImage: editor.setNewImage) + } + + private func resetTunner(_ pickerPresenting: Bool) { + guard !isShowingPicker else { return } + withAnimation { + editor.colorControl = ImageColorControl.defaults + } + } + + private func showPickerIfNeeded() { + guard editor.delegate == nil else { + return + } + editor.delegate = self + if editor.imageForDisplay == nil { + DispatchQueue.main.async { + isShowingPicker = true + } + } + } + + // Editor delegation + func savingCompletion(error: Error?) { + if error == nil { + withAnimation{ + feedBackImage = Image(systemName: "checkmark") + } + }else { + print("Fail to save image: \(error!.localizedDescription)") + } + } +} + +struct EditView_Previews: PreviewProvider { + static var previews: some View { + EditView() + .environmentObject(ImageEditor.forPreview) + } +} diff --git a/moody/EditingImage.swift b/moody/EditingImage.swift new file mode 100644 index 0000000..602e700 --- /dev/null +++ b/moody/EditingImage.swift @@ -0,0 +1,192 @@ +// +// EditingImage.swift +// moody +// +// Created by bart Shin on 24/06/2021. +// + +import SwiftUI +import PencilKit + +struct EditingImage: View { + + @EnvironmentObject var editor: ImageEditor + @Binding var currentControl: String + + private var image: UIImage { + editor.imageForDisplay! + } + + private var isDrawingMask: Bool { + currentControl == ImageBlurControl.mask.rawValue + } + + var body: some View { + if editor.imageForDisplay != nil { + GeometryReader { geometry in + drawImage(in: geometry.size) + .scaleEffect(zoomScale) + .position(x: geometry.size.width / 2 + panningOffset.width, + y: geometry.size.height / 2 + panningOffset.height) + .gesture(panGesture(in: geometry.size) + .simultaneously(with: zoomGesture(in: geometry.size)) + .simultaneously(with: doubleTapGesture(in: geometry.size))) + .allowsHitTesting(!isDrawingMask) + .clipped() + } + } + } + + private func drawImage(in size: CGSize) -> some View { + Image(uiImage: image) + .aspectRatio(contentMode: .fit) + .onAppear { + fixedZoomScale = getScaleToFit(in: size) + editor.blurMarkerWidth = min(60 / fixedZoomScale, 60) + } + .onChange(of: editor.originalImage) { + if $0 != nil { + zoomToFit(for: size) + editor.blurMask.drawing.strokes = [] + } + } + .onChange(of: currentControl) { + if $0 == ImageBlurControl.mask.rawValue { + zoomToFit(for: size) + }else { + editor.blurMask.drawing.strokes = [] + } + } + .overlay( + BlurMaskView(canvas: $editor.blurMask, markerWidth: $editor.blurMarkerWidth) + .colorInvert() + .allowsHitTesting(isDrawingMask) + ) + } + + + //MARK:- Zooming + + @State private var fixedZoomScale: CGFloat = 1 + @GestureState private var gestureZoomScale: CGFloat = 1 + + private var zoomScale: CGFloat { + fixedZoomScale * gestureZoomScale + } + + private func zoomGesture(in size: CGSize) -> some Gesture { + MagnificationGesture() + .updating($gestureZoomScale) { lastScale, gestureZoomScale, _ in + guard !isDrawingMask else { return } + gestureZoomScale = lastScale + } + .onEnded { scale in + guard !isDrawingMask else { return } + let defaultScale = getScaleToFit(in: size) + fixedZoomScale *= scale + if defaultScale > fixedZoomScale * scale { + withAnimation { + fixedZoomScale = defaultScale + fixedPanOffset = .zero + } + } + } + } + + private func doubleTapGesture(in size: CGSize) -> some Gesture { + TapGesture(count: 2) + .onEnded { + zoomToFit(for: size) + } + } + + private func zoomToFit(for size: CGSize) { + withAnimation { + fixedPanOffset = .zero + fixedZoomScale = getScaleToFit(in: size) + } + } + + private func getScaleToFit(in size: CGSize) -> CGFloat { + let horizontal = size.width / image.size.width + let vertical = size.height / image.size.height + return min(horizontal, vertical) + } + + + //MARK:- Panning + + @State private var fixedPanOffset: CGSize = CGSize.zero + @GestureState private var gesturePanOffset: CGSize = CGSize.zero + + private var panningOffset: CGSize { + CGSize(width: (fixedPanOffset.width + gesturePanOffset.width) * zoomScale, + height: (fixedPanOffset.height + gesturePanOffset.height) * zoomScale) + } + + private func panGesture(in size: CGSize) -> some Gesture { + DragGesture() + + .updating($gesturePanOffset) { lastestDragGestureValue, gesturePanOffset, _ in + guard calcPanableSpace(in: size) != nil else { + return + } + gesturePanOffset = CGSize( + width: lastestDragGestureValue.translation.width / zoomScale, + height: lastestDragGestureValue.translation.height / zoomScale) + } + .onEnded { endValue in + guard let panableSpace = calcPanableSpace(in: size) else { + return + } + fixedPanOffset = CGSize( + width: fixedPanOffset.width + endValue.translation.width / zoomScale, + height: fixedPanOffset.height + endValue.translation.height / zoomScale) + + if checkExceedEdge(in: panableSpace) { + withAnimation { + fixedPanOffset = calcMaxiumOffset(in: panableSpace) + } + } + } + } + + private func checkExceedEdge(in panableSpace: CGSize) -> Bool { + abs(panningOffset.width) > panableSpace.width || + abs(panningOffset.height) > panableSpace .height + } + + private func calcMaxiumOffset(in panableSpace: CGSize) -> CGSize { + let currentOffset = panningOffset + let horizontal: CGFloat + let vertical: CGFloat + if abs(currentOffset.width) > panableSpace.width { + horizontal = panableSpace.width * (currentOffset.width < 0 ? -1: 1) + }else { + horizontal = currentOffset.width + } + if abs(panningOffset.height) > panableSpace.height { + vertical = panableSpace.height * (currentOffset.height < 0 ? -1: 1) + }else { + vertical = currentOffset.height + } + return CGSize(width: horizontal / zoomScale, height: vertical / zoomScale) + } + private func calcPanableSpace(in viewSize: CGSize) -> CGSize? { + let defaultZoomScale = getScaleToFit(in: viewSize) + guard zoomScale > defaultZoomScale else { + return nil + } + return CGSize( + width: image.size.width * (zoomScale - defaultZoomScale) / 2, + height: image.size.height * (zoomScale - defaultZoomScale) / 2) + } +} + + +struct EditingImage_Previews: PreviewProvider { + static var previews: some View { + EditingImage(currentControl: Binding.constant(ImageColorControl.brightness.rawValue)) + .environmentObject(ImageEditor.forPreview) + } +} diff --git a/moody/FeedBackView.swift b/moody/FeedBackView.swift new file mode 100644 index 0000000..6c8ea9e --- /dev/null +++ b/moody/FeedBackView.swift @@ -0,0 +1,40 @@ +// +// FeedBackView.swift +// moody +// +// Created by bart Shin on 24/06/2021. +// + +import SwiftUI + +struct FeedBackView: View { + @Binding var feedBackImage: Image? + + private var size: CGFloat { + min(UIScreen.main.bounds.width * 0.5, UIScreen.main.bounds.height * 0.5) + } + + var body: some View { + + if feedBackImage != nil { + feedBackImage! + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .padding(size * 0.3) + .overlay(Circle() + .stroke(lineWidth: 5)) + .foregroundColor(.white) + .onAppear(perform: hideFeedback) + } + } + + private func hideFeedback() { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + withAnimation { + feedBackImage = nil + } + } + } +} + diff --git a/moody/FilterView.swift b/moody/FilterView.swift new file mode 100644 index 0000000..c6ba4ab --- /dev/null +++ b/moody/FilterView.swift @@ -0,0 +1,71 @@ +// +// FilterView.swift +// moody +// +// Created by bart Shin on 21/06/2021. +// + +import SwiftUI + +struct FilterView: View { + + @StateObject private var homeData = HomeViewModel() + + var body: some View { + VStack { + if !homeData.allImages.isEmpty && homeData.mainView != nil{ + + Image(uiImage: homeData.mainView.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: UIScreen.main.bounds.width) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + ForEach(homeData.allImages){ + filtered in + Image(uiImage: filtered.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 150, height: 150) + .onTapGesture { + homeData.mainView = filtered + } + } + } + } + .padding() + } + else if homeData.imageData.count == 0 { + Text("Pick An Image To Progress") + } + else { + ProgressView() + } + } + .navigationTitle("Filter") + .onChange(of: homeData.imageData, perform: { (_) in + // When Ever image is changed Firing loadImage + homeData.allImages.removeAll() + homeData.loadFilter() + }) + .toolbar { + // image button + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: {homeData.imagePicker.toggle()}) { + Image(systemName: "photo") + .font(.title2) + } + } + } + .sheet(isPresented: $homeData.imagePicker) { + ImagePicker(picker: $homeData.imagePicker, imageData: $homeData.imageData) + } + } +} + +struct FilterView_Previews: PreviewProvider { + static var previews: some View { + FilterView() + } +} diff --git a/moody/Helper/BottomNavigationButton.swift b/moody/Helper/BottomNavigationButton.swift new file mode 100644 index 0000000..bae27b0 --- /dev/null +++ b/moody/Helper/BottomNavigationButton.swift @@ -0,0 +1,23 @@ +// +// BottomNavigationButton.swift +// moody +// +// Created by bart Shin on 21/06/2021. +// + +import SwiftUI + +struct BottomNavigation: ButtonStyle { + + let size: CGFloat = 50 + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .clipShape(Circle()) + .frame(width: size, height: size) + .scaleEffect(configuration.isPressed ? 1.2: 1) + .animation(.easeInOut, value: configuration.isPressed) + } +} + + diff --git a/moody/Helper/ImageEditFactor.swift b/moody/Helper/ImageEditFactor.swift new file mode 100644 index 0000000..4f6685a --- /dev/null +++ b/moody/Helper/ImageEditFactor.swift @@ -0,0 +1,46 @@ +// +// ImageColorControl.swift +// moody +// +// Created by bart Shin on 21/06/2021. +// + +import SwiftUI + +enum ImageBlurControl: String { + case mask +} + +enum ImageColorControl: String, Hashable, CaseIterable { + + case brightness = "밝기" + case saturation = "채도" + case contrast = "대비" + + var defaultValue: Double { + switch self { + case .brightness: + return 0 + case .saturation, .contrast: + return 1 + } + } + + static var defaults: [Self: Double] { + Self.allCases.reduce(into: [Self: Double]()) { + $0[$1] = $1.defaultValue + } + } + + var label: Image { + switch self { + case .brightness: + return Image(systemName: "sun.max") + case .saturation: + return Image(systemName: "drop.fill") + case .contrast: + return Image(systemName: "circle.lefthalf.fill") + } + } + +} diff --git a/moody/Helper/Publish.swift b/moody/Helper/Publish.swift new file mode 100644 index 0000000..0c15ff7 --- /dev/null +++ b/moody/Helper/Publish.swift @@ -0,0 +1,21 @@ +// +// Publish.swift +// moody +// +// Created by bart Shin on 26/06/2021. + +// +//import Foundation +//import Combine +// +//extension ObservableObject where Self.ObjectWillChangePublisher == ObservableObjectPublisher { +// func publishOnMainThread() { +// if Thread.isMainThread { +// objectWillChange.send() +// }else { +// DispatchQueue.main.async { +// self.objectWillChange.send() +// } +// } +// } +//} diff --git a/moody/Home.swift b/moody/Home.swift deleted file mode 100644 index 32f9028..0000000 --- a/moody/Home.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// Home.swift -// moody -// -// Created by 김두리 on 2021/06/18. -// - -import SwiftUI - -struct Home: View { - - @StateObject var homeData = HomeViewModel() - var body: some View { - - VStack { - if !homeData.allImages.isEmpty && homeData.mainView != nil{ - - Image(uiImage: homeData.mainView.image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: UIScreen.main.bounds.width) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 20) { - ForEach(homeData.allImages){ - filtered in - Image(uiImage: filtered.image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 150, height: 150) - - .onTapGesture { - homeData.mainView = filtered - } - } - } - .padding() - } - } - else if homeData.imageData.count == 0 { - Text("Pick An Image To Progress") - } - else { - ProgressView() - } - } - .onChange(of: homeData.imageData, perform: { (_) in - // When Ever image is changed Firing loadImage - homeData.allImages.removeAll() - homeData.loadFilter() - }) - .toolbar { - // image button - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: {homeData.imagePicker.toggle()}) { - Image(systemName: "photo") - .font(.title2) - } - } - } - .sheet(isPresented: $homeData.imagePicker) { - ImagePicker(picker: $homeData.imagePicker, imageData: $homeData.imageData) - } - } -} diff --git a/moody/HomeView.swift b/moody/HomeView.swift new file mode 100644 index 0000000..bb4bd12 --- /dev/null +++ b/moody/HomeView.swift @@ -0,0 +1,40 @@ +// +// Home.swift +// moody +// +// Created by 김두리 on 2021/06/18. +// + +import SwiftUI + +struct HomeView: View { + + @State private var navigationDestination: String? + + var body: some View { + VStack { + Text("Camera") + .foregroundColor(.primary) + .frame(width: UIScreen.main.bounds.width, + height: UIScreen.main.bounds.width) + + BottomNavigationBar(navigationTag: $navigationDestination) + .padding(.top, Constant.verticalMargin) + } + .padding(.vertical, Constant.verticalMargin) + .navigationBarTitle("Home") + } + + + struct Constant { + static let verticalMargin: CGFloat = 50 + } +} + +struct HomeView_Previews: PreviewProvider { + static var previews: some View { + HomeView() + .preferredColorScheme(.dark) + .environmentObject(ImageEditor.forPreview) + } +} diff --git a/moody/ImagePicker.swift b/moody/ImagePicker.swift index a41d3cc..f12b385 100644 --- a/moody/ImagePicker.swift +++ b/moody/ImagePicker.swift @@ -17,6 +17,7 @@ struct ImagePicker: UIViewControllerRepresentable { @Binding var picker: Bool @Binding var imageData: Data + let passImage: ((UIImage) -> Void)? func makeUIViewController(context: Context) -> PHPickerViewController { let picker = PHPickerViewController(configuration: PHPickerConfiguration()) @@ -36,27 +37,36 @@ struct ImagePicker: UIViewControllerRepresentable { init(parent: ImagePicker) { self.parent = parent - } - - func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - // checking image is selected or cacelled - - if !results.isEmpty { - // checking image can be loaded - if results.first!.itemProvider.canLoadObject(ofClass: UIImage.self){ - results.first!.itemProvider.loadObject(ofClass: UIImage.self) { - (image, error) in - DispatchQueue.main.async { - self.parent.imageData = (image as! UIImage).pngData()! - self.parent.picker.toggle() - } - } - }else { - self.parent.picker.toggle() - } - } - + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + // checking image is selected or cacelled + + if !results.isEmpty { + // checking image can be loaded + if results.first!.itemProvider.canLoadObject(ofClass: UIImage.self){ + results.first!.itemProvider.loadObject(ofClass: UIImage.self) { [self] + (image, error) in + if error == nil, + let uiImage = image as? UIImage { + parent.passImage?(uiImage) + } + DispatchQueue.main.async { + self.parent.imageData = (image as! UIImage).pngData()! + self.parent.picker.toggle() + } + } + } + }else { + self.parent.picker.toggle() + } } } + + init(picker: Binding, imageData: Binding, passImage: ((UIImage) -> Void)? = nil ) { + self._picker = picker + self._imageData = imageData + self.passImage = passImage + } } diff --git a/moody/Info.plist b/moody/Info.plist index efc211a..6aa8e8a 100644 --- a/moody/Info.plist +++ b/moody/Info.plist @@ -39,6 +39,8 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSPhotoLibraryAddUsageDescription + 완성된 사진을 저장하기 위해 사용자의 앨범에 접근을 허용해주세요. UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait diff --git a/moody/Persistence.swift b/moody/Persistence.swift index 92a7c0b..0af9899 100644 --- a/moody/Persistence.swift +++ b/moody/Persistence.swift @@ -8,12 +8,12 @@ import CoreData struct PersistenceController { - static let shared = PersistenceController() + static let shared = PersistenceController() - static var preview: PersistenceController = { - let result = PersistenceController(inMemory: true) - let viewContext = result.container.viewContext - for _ in 0..<10 { + static var preview: PersistenceController = { + let result = PersistenceController(inMemory: true) + let viewContext = result.container.viewContext + for _ in 0..<10 { let newItem = Item(context: viewContext) newItem.timestamp = Date() } diff --git a/moody/TuningPanel.swift b/moody/TuningPanel.swift new file mode 100644 index 0000000..2cae34a --- /dev/null +++ b/moody/TuningPanel.swift @@ -0,0 +1,109 @@ +// +// TuningPanel.swift +// moody +// +// Created by bart Shin on 21/06/2021. +// + +import SwiftUI + +struct TuningPanel: View { + + @EnvironmentObject var editor: ImageEditor + @Binding var currentControl: String + + var body: some View { + + VStack { + HStack { + ForEach(ImageColorControl.allCases, id: \.rawValue) { + drawButton(for: $0) + } + blurButton + } + controlLabel + if let colorControl = ImageColorControl(rawValue: currentControl) { + drawSlider(for: colorControl) + }else { + blurControlSlider + } + } + .padding(.horizontal, Constant.horizontalPadding) + .padding(.vertical, Constant.verticalPadding) + } + + private var controlLabel: some View { + Text(currentControl) + .foregroundColor(.blue) + } + + private var blurButton: some View { + Button(action: { + withAnimation { + currentControl = ImageBlurControl.mask.rawValue + } + }) { + Image(systemName: "lasso") + } + .buttonStyle(BottomNavigation()) + .foregroundColor(currentControl == ImageBlurControl.mask.rawValue ? .yellow: .white) + .scaleEffect(currentControl == ImageBlurControl.mask.rawValue ? 1.3: 1) + } + + private var blurControlSlider: some View { + VStack { + HStack { + Text("강도") + Slider(value: $editor.blurIntensity, in: 0...20, step: 1) + } + HStack { + Text("범위") + Slider(value: $editor.blurMarkerWidth, in: 10...60, step: 5) + } + } + .padding() + } + + private func drawButton(for colorControl: ImageColorControl) -> some View { + Button(action: { + withAnimation{ + currentControl = colorControl.rawValue + } + }) { + colorControl.label + } + .buttonStyle(BottomNavigation()) + .foregroundColor(colorControl.rawValue == currentControl ? .yellow: .white) + .scaleEffect(colorControl.rawValue == currentControl ? 1.3: 1) + .padding(.horizontal) + } + + private func drawSlider(for colorControl: ImageColorControl) -> some View { + Slider(value: createBinding(to: colorControl), in: -0.5...0.5, + step: 0.05) { + // For accessbility + controlLabel + } + } + + private func createBinding(to colorControl: ImageColorControl) -> Binding { + Binding { + editor.colorControl[colorControl]! - colorControl.defaultValue + } set: { + editor.colorControl[colorControl] = $0 + colorControl.defaultValue + } + } + + + struct Constant { + static let horizontalPadding: CGFloat = 50 + static let verticalPadding: CGFloat = 30 + } + +} + +struct ImageTuningPanel_Previews: PreviewProvider { + static var previews: some View { + TuningPanel(currentControl: Binding.constant(ImageColorControl.brightness.rawValue)) + } +} diff --git a/moody/ViewModel/HomeViewModel.swift b/moody/ViewModel/HomeViewModel.swift index 2232612..1524b01 100644 --- a/moody/ViewModel/HomeViewModel.swift +++ b/moody/ViewModel/HomeViewModel.swift @@ -10,6 +10,7 @@ import CoreImage import CoreImage.CIFilterBuiltins class HomeViewModel: ObservableObject { + @Published var imagePicker = false @Published var imageData = Data(count: 0) diff --git a/moody/ViewModel/ImageEditor.swift b/moody/ViewModel/ImageEditor.swift new file mode 100644 index 0000000..5ec9068 --- /dev/null +++ b/moody/ViewModel/ImageEditor.swift @@ -0,0 +1,136 @@ +// +// ImageEditor.swift +// moody +// +// Created by bart Shin on 21/06/2021. +// + +import CoreImage +import SwiftUI +import PencilKit + +class ImageEditor: NSObject, ObservableObject { + + static var forPreview: ImageEditor { + let editor = ImageEditor() + editor.setNewImage(UIImage(named: "selfie_dummy")!) + return editor + } + + private(set) var originalImage: CGImage? + private var imageOrientation: UIImage.Orientation? + var imageForDisplay: UIImage? + var blurMask: PKCanvasView + var blurIntensity: Double + @Published var blurMarkerWidth: CGFloat + + var colorControl: [ImageColorControl: Double] { + didSet { + setImageForDisplay() + } + } + + var delegate: EditorDelegation? + private lazy var ciContext = CIContext(options: nil) + + func setNewImage(_ image: UIImage) { + originalImage = image.cgImage + imageOrientation = image.imageOrientation + setImageForDisplay() + } + + func applyColorFilter() -> CIImage? { + let colorControlFilter = createColorControlFilter() + colorControlFilter.setValue(CIImage(cgImage: originalImage!), + forKey: "inputImage") + return colorControlFilter.outputImage + } + + fileprivate func createColorControlFilter() -> CIFilter { + let filter = CIFilter(name: "CIColorControls")! + filter.setValue(colorControl[.brightness], forKey: kCIInputBrightnessKey) + filter.setValue(colorControl[.contrast], forKey: kCIInputContrastKey) + filter.setValue(colorControl[.saturation], forKey: kCIInputSaturationKey) + return filter + } + + func applyBlurByMask() { + let sourceImage = CIImage(cgImage: originalImage!) + guard let mask = CIImage(image: blurMask.drawing.image(from: sourceImage.extent, scale: 1)) else { + assertionFailure("Fail to create mask image") + return + } + let blurFilter = CIFilter(name: "CIMaskedVariableBlur")! + blurFilter.setValue(mask, forKey: "inputMask") + blurFilter.setValue(sourceImage, forKey: kCIInputImageKey) + blurFilter.setValue(blurIntensity, forKey: kCIInputRadiusKey) + if let outputImage = blurFilter.outputImage, + let cgImage = ciContext.createCGImage(outputImage, from: sourceImage.extent) { + originalImage = cgImage + setImageForDisplay() + }else { + print("Fail to apply blur") + } + } + + func saveImage() { + guard imageForDisplay != nil else { + savingCompletion(UIImage(), + didFinishSavingWithError: ProcessError.convertingError, contextInfo: nil) + return + } + UIImageWriteToSavedPhotosAlbum(imageForDisplay!, self, + #selector(savingCompletion(_:didFinishSavingWithError:contextInfo:)), nil) + } + + @objc fileprivate func savingCompletion(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer?) { + delegate?.savingCompletion(error: error) + } + + private func setImageForDisplay() { + if let ciImage = applyColorFilter(), + let cgImage = ciContext.createCGImage(ciImage, from: ciImage.extent), + imageOrientation != nil{ + imageForDisplay = UIImage(cgImage: cgImage, scale: 1, orientation: imageOrientation!) + } + publishOnMainThread() + } + + private func publishOnMainThread() { + if Thread.isMainThread { + objectWillChange.send() + }else { + DispatchQueue.main.async { + self.objectWillChange.send() + } + } + } + + enum ProcessError: Error { + case convertingError + } + + override init() { + colorControl = ImageColorControl.defaults + blurMask = PKCanvasView() + blurIntensity = 10 + blurMarkerWidth = 30 + super.init() + blurMask.delegate = self + } +} + +extension ImageEditor: PKCanvasViewDelegate { + func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { + guard !canvasView.drawing.strokes.isEmpty else { + return + } + DispatchQueue.global(qos: .userInitiated).async { + self.applyBlurByMask() + } + } +} + +protocol EditorDelegation { + func savingCompletion(error: Error?) -> Void +}