From 0c5a8464feabcd23ce0aee1e8292d33a7420da73 Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Wed, 4 Dec 2019 03:38:18 -0600 Subject: [PATCH] Implement changes for Day 66 --- .../Instafilter.xcodeproj/project.pbxproj | 32 ++- .../Constants/CoreImageFilterNames.swift | 74 +++++++ .../UIImagePickerWrapper+Coordinator.swift | 8 +- .../UIImagePickerWrapper.swift | 8 +- .../Filtering/FilterSelectionView.swift | 69 +++++++ .../ImageFilteringContainerView.swift | 57 +++--- .../ImageFilteringContainerViewModel.swift | 50 ++++- .../Scenes/Filtering/ImageFilteringView.swift | 193 ++++++++++++++++++ .../Filtering/ImageFilteringViewModel.swift | 164 +++++++++++++++ .../Scenes/ImageFilteringView.swift | 108 ---------- .../Scenes/ImageFilteringViewModel.swift | 119 ----------- 11 files changed, 608 insertions(+), 274 deletions(-) create mode 100644 day-062/Projects/Instafilter/Instafilter/Reusables/Constants/CoreImageFilterNames.swift create mode 100644 day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/FilterSelectionView.swift rename day-062/Projects/Instafilter/Instafilter/Scenes/{ => Filtering}/ImageFilteringContainerView.swift (71%) rename day-062/Projects/Instafilter/Instafilter/Scenes/{ => Filtering}/ImageFilteringContainerViewModel.swift (62%) create mode 100644 day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringView.swift create mode 100644 day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringViewModel.swift delete mode 100644 day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringView.swift delete mode 100644 day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringViewModel.swift diff --git a/day-062/Projects/Instafilter/Instafilter.xcodeproj/project.pbxproj b/day-062/Projects/Instafilter/Instafilter.xcodeproj/project.pbxproj index 4f85667..3146061 100644 --- a/day-062/Projects/Instafilter/Instafilter.xcodeproj/project.pbxproj +++ b/day-062/Projects/Instafilter/Instafilter.xcodeproj/project.pbxproj @@ -22,9 +22,11 @@ F3524254238F3F19009DF1F9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F3524253238F3F19009DF1F9 /* Assets.xcassets */; }; F3524257238F3F19009DF1F9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F3524256238F3F19009DF1F9 /* Preview Assets.xcassets */; }; F352425A238F3F19009DF1F9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F3524258238F3F19009DF1F9 /* LaunchScreen.storyboard */; }; + F36D28E223962F8C00095B66 /* CoreImageFilterNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36D28E123962F8B00095B66 /* CoreImageFilterNames.swift */; }; F3F566AF238F6556009E1FB0 /* ImageFilteringService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3F566AE238F6556009E1FB0 /* ImageFilteringService.swift */; }; F3F566B2238F68E2009E1FB0 /* CypherPoetSwiftUIKit in Frameworks */ = {isa = PBXBuildFile; productRef = F3F566B1238F68E2009E1FB0 /* CypherPoetSwiftUIKit */; }; F3F566B6238F6A48009E1FB0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3F566B5238F6A48009E1FB0 /* AppState.swift */; }; + F3F656E82395CC44007AC32E /* FilterSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3F656E72395CC44007AC32E /* FilterSelectionView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -45,8 +47,10 @@ F3524256238F3F19009DF1F9 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; F3524259238F3F19009DF1F9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; F352425B238F3F19009DF1F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F36D28E123962F8B00095B66 /* CoreImageFilterNames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreImageFilterNames.swift; sourceTree = ""; }; F3F566AE238F6556009E1FB0 /* ImageFilteringService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFilteringService.swift; sourceTree = ""; }; F3F566B5238F6A48009E1FB0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + F3F656E72395CC44007AC32E /* FilterSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterSelectionView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -130,10 +134,7 @@ F3524261238F45C9009DF1F9 /* Scenes */ = { isa = PBXGroup; children = ( - F329952D23908BD400D2D963 /* ImageFilteringView.swift */, - F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */, - F32995422393199B00D2D963 /* ImageFilteringContainerViewModel.swift */, - F329952F23908CB500D2D963 /* ImageFilteringViewModel.swift */, + F3F656E52395CBFF007AC32E /* Filtering */, ); path = Scenes; sourceTree = ""; @@ -141,6 +142,7 @@ F3524262238F45CD009DF1F9 /* Reusables */ = { isa = PBXGroup; children = ( + F36D28E023962F7700095B66 /* Constants */, F32995332391817800D2D963 /* Views */, F3F566AE238F6556009E1FB0 /* ImageFilteringService.swift */, F329953E23920AE800D2D963 /* ImageWriter.swift */, @@ -156,6 +158,14 @@ path = Resources; sourceTree = ""; }; + F36D28E023962F7700095B66 /* Constants */ = { + isa = PBXGroup; + children = ( + F36D28E123962F8B00095B66 /* CoreImageFilterNames.swift */, + ); + path = Constants; + sourceTree = ""; + }; F3F566B3238F6A39009E1FB0 /* Data */ = { isa = PBXGroup; children = ( @@ -174,6 +184,18 @@ path = State; sourceTree = ""; }; + F3F656E52395CBFF007AC32E /* Filtering */ = { + isa = PBXGroup; + children = ( + F329952D23908BD400D2D963 /* ImageFilteringView.swift */, + F3F656E72395CC44007AC32E /* FilterSelectionView.swift */, + F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */, + F32995422393199B00D2D963 /* ImageFilteringContainerViewModel.swift */, + F329952F23908CB500D2D963 /* ImageFilteringViewModel.swift */, + ); + path = Filtering; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -259,8 +281,10 @@ F329953F23920AE800D2D963 /* ImageWriter.swift in Sources */, F32995322390C29100D2D963 /* ImageFilteringContainerView.swift in Sources */, F32995432393199B00D2D963 /* ImageFilteringContainerViewModel.swift in Sources */, + F3F656E82395CC44007AC32E /* FilterSelectionView.swift in Sources */, F352424E238F3F18009DF1F9 /* AppDelegate.swift in Sources */, F329953023908CB500D2D963 /* ImageFilteringViewModel.swift in Sources */, + F36D28E223962F8C00095B66 /* CoreImageFilterNames.swift in Sources */, F3299535239181A900D2D963 /* UIImagePickerWrapper.swift in Sources */, F329953D2391F1D000D2D963 /* SampleData.swift in Sources */, F3524250238F3F18009DF1F9 /* SceneDelegate.swift in Sources */, diff --git a/day-062/Projects/Instafilter/Instafilter/Reusables/Constants/CoreImageFilterNames.swift b/day-062/Projects/Instafilter/Instafilter/Reusables/Constants/CoreImageFilterNames.swift new file mode 100644 index 0000000..8df6b4b --- /dev/null +++ b/day-062/Projects/Instafilter/Instafilter/Reusables/Constants/CoreImageFilterNames.swift @@ -0,0 +1,74 @@ +// +// CoreImageFilterNames.swift +// Instafilter +// +// Created by CypherPoet on 12/2/19. +// ✌️ +// + +import Foundation + + +enum CoreImageFilter { + case bokehBlur + case crystallize + case edges + case gaussianBlur + case pixellate + case sepiaTone + case unsharpMask + case vignette +} + +extension CoreImageFilter: CaseIterable {} +extension CoreImageFilter: Identifiable { var id: String { ciFilterName }} + + + + +extension CoreImageFilter { + + var ciFilterName: String { + switch self { + case .bokehBlur: + return "CIBokehBlur" + case .crystallize: + return "CICrystallize" + case .edges: + return "CIEdges" + case .gaussianBlur: + return "CIGaussianBlur" + case .pixellate: + return "CIPixellate" + case .sepiaTone: + return "CISepiaTone" + case .unsharpMask: + return "CIUnsharpMask" + case .vignette: + return "CIVignette" + } + } + + + var displayName: String { + switch self { + case .bokehBlur: + return "Bokeh Blur" + case .crystallize: + return "Crystallize" + case .edges: + return "Edges" + case .gaussianBlur: + return "Gaussian Blur" + case .pixellate: + return "Pixellate" + case .sepiaTone: + return "Sepia Tone" + case .unsharpMask: + return "Unsharp Mask" + case .vignette: + return "Vignette" + } + } +} + diff --git a/day-062/Projects/Instafilter/Instafilter/Reusables/Views/UIImagePickerWrapper/UIImagePickerWrapper+Coordinator.swift b/day-062/Projects/Instafilter/Instafilter/Reusables/Views/UIImagePickerWrapper/UIImagePickerWrapper+Coordinator.swift index 3ff9703..5a0d04c 100644 --- a/day-062/Projects/Instafilter/Instafilter/Reusables/Views/UIImagePickerWrapper/UIImagePickerWrapper+Coordinator.swift +++ b/day-062/Projects/Instafilter/Instafilter/Reusables/Views/UIImagePickerWrapper/UIImagePickerWrapper+Coordinator.swift @@ -14,9 +14,9 @@ import SwiftUI extension UIImagePickerWrapper { class Coordinator: NSObject { - let onSelect: ((UIImage?) -> Void) + let onSelect: ((UIImage) -> Void) - init(onSelect: @escaping ((UIImage?) -> Void)) { + init(onSelect: @escaping ((UIImage) -> Void)) { self.onSelect = onSelect } } @@ -31,7 +31,9 @@ extension UIImagePickerWrapper.Coordinator: UIImagePickerControllerDelegate { _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any] ) { - let selectedImage = (info[.editedImage] ?? info[.originalImage]) as? UIImage + guard let selectedImage = (info[.editedImage] ?? info[.originalImage]) as? UIImage else { + fatalError() + } onSelect(selectedImage) } diff --git a/day-062/Projects/Instafilter/Instafilter/Reusables/Views/UIImagePickerWrapper/UIImagePickerWrapper.swift b/day-062/Projects/Instafilter/Instafilter/Reusables/Views/UIImagePickerWrapper/UIImagePickerWrapper.swift index 5941d57..2acf455 100644 --- a/day-062/Projects/Instafilter/Instafilter/Reusables/Views/UIImagePickerWrapper/UIImagePickerWrapper.swift +++ b/day-062/Projects/Instafilter/Instafilter/Reusables/Views/UIImagePickerWrapper/UIImagePickerWrapper.swift @@ -14,7 +14,7 @@ struct UIImagePickerWrapper { @Environment(\.presentationMode) var presentationMode - @Binding var selectedImage: UIImage? + let onSelect: ((UIImage) -> Void) } @@ -22,7 +22,7 @@ struct UIImagePickerWrapper { extension UIImagePickerWrapper: UIViewControllerRepresentable { func makeCoordinator() -> UIImagePickerWrapper.Coordinator { - Self.Coordinator(onSelect: imageSelected) + Self.Coordinator(onSelect: self.imageSelected(_:)) } @@ -48,8 +48,8 @@ extension UIImagePickerWrapper: UIViewControllerRepresentable { private extension UIImagePickerWrapper { - func imageSelected(_ image: UIImage?) { - selectedImage = image + func imageSelected(_ image: UIImage) { + onSelect(image) presentationMode.wrappedValue.dismiss() } } diff --git a/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/FilterSelectionView.swift b/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/FilterSelectionView.swift new file mode 100644 index 0000000..e027db9 --- /dev/null +++ b/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/FilterSelectionView.swift @@ -0,0 +1,69 @@ +// +// FilterSelectionView.swift +// Instafilter +// +// Created by CypherPoet on 12/2/19. +// ✌️ +// + +import SwiftUI +import CoreImage +import CoreImage.CIFilterBuiltins + + +struct FilterSelectionView: View { + @Environment(\.presentationMode) private var presentationMode + + /// available filters + let options: [CoreImageFilter] + + let onSelect: ((CoreImageFilter) -> Void) +} + + +// MARK: - Body +extension FilterSelectionView { + + var body: some View { + List { + ForEach(options) { filterOption in + Button(action: { + self.onSelect(filterOption) + self.presentationMode.wrappedValue.dismiss() + }) { + Text(filterOption.displayName) + } + } + } + } +} + + +// MARK: - Computeds +extension FilterSelectionView { + + +} + + +// MARK: - View Variables +extension FilterSelectionView { + + +} + + + +// MARK: - Preview +struct FilterSelectionView_Previews: PreviewProvider { + + static var previews: some View { + FilterSelectionView( + options: [ + CoreImageFilter.bokehBlur, + CoreImageFilter.sepiaTone, + ], + onSelect: { _ in } + ) + } +} diff --git a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerView.swift b/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringContainerView.swift similarity index 71% rename from day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerView.swift rename to day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringContainerView.swift index b3c043f..8d47e63 100644 --- a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerView.swift +++ b/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringContainerView.swift @@ -11,11 +11,8 @@ import Combine struct ImageFilteringContainerView: View { @EnvironmentObject var store: AppStore - @ObservedObject private(set) var viewModel = ImageFilteringContainerViewModel() - - - @State private var currentInputImage: UIImage? = nil + @State private var isShowingImagePicker = false @State private var isShowingWritingAuthError = false } @@ -28,8 +25,11 @@ extension ImageFilteringContainerView { NavigationView { VStack(spacing: 42.0) { Group { - if currentInputImage != nil { - ImageFilteringView(inputImage: currentInputImage!, store: store) + if viewModel.isShowingFilterView { + ImageFilteringView( + viewModel: viewModel.filteringViewModel, + onSave: save(filteredImage:) + ) } else { Spacer() @@ -40,19 +40,21 @@ extension ImageFilteringContainerView { } } } - .navigationBarTitle("📸 Instafilter") - .navigationBarItems(trailing: saveButton) + .navigationBarTitle( + viewModel.isShowingFilterView ? "" : "📸 Instafilter", + displayMode: viewModel.isShowingFilterView ? .inline : .large + ) } .onAppear { self.viewModel.store = self.store } .sheet(isPresented: $isShowingImagePicker) { - UIImagePickerWrapper(selectedImage: self.$currentInputImage) + UIImagePickerWrapper(onSelect: self.inputImageSelected(_:)) } - .alert(isPresented: $isShowingWritingAuthError) { self.imageWritingAuthErrorAlert } - .alert(isPresented: $viewModel.hasImageWritingError) { self.imageWritingErrorAlert } + .alert(isPresented: $viewModel.isShowingErrorMessage) { self.errorAlert } } } + // MARK: - Computeds extension ImageFilteringContainerView { var filteredImagesState: FilteredImagesState { store.state.filteredImages } @@ -60,7 +62,6 @@ extension ImageFilteringContainerView { } - // MARK: - View Variables extension ImageFilteringContainerView { @@ -91,18 +92,13 @@ extension ImageFilteringContainerView { .shadow(color: .gray, radius: 8, x: 0, y: 0) } - - private var saveButton: some View { - Button(action: { - if self.viewModel.hasImageWritingAuth { - self.saveFilteredImage() - } else { - self.isShowingWritingAuthError = true - } - }) { - Text("Save") + + private var errorAlert: Alert { + if viewModel.hasAuthError { + return imageWritingAuthErrorAlert + } else { + return imageWritingErrorAlert } - .disabled(filteredOutputCGImage == nil) } @@ -128,12 +124,17 @@ extension ImageFilteringContainerView { // MARK: - Private Helpers private extension ImageFilteringContainerView { - func saveFilteredImage() { - guard let filteredCGImage = filteredOutputCGImage else { - preconditionFailure("No output image available for saving") + func save(filteredImage: CGImage) { + if viewModel.hasImageWritingAuth { + store.send(ImageWritingSideEffect.saveOutput(image: filteredImage)) + } else { + viewModel.hasAuthError = true } - - store.send(ImageWritingSideEffect.saveOutput(image: filteredCGImage)) + } + + + func inputImageSelected(_ image: UIImage) { + viewModel.currentInputImage = image } } diff --git a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerViewModel.swift b/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringContainerViewModel.swift similarity index 62% rename from day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerViewModel.swift rename to day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringContainerViewModel.swift index 9634938..f0b0cf8 100644 --- a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerViewModel.swift +++ b/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringContainerViewModel.swift @@ -13,6 +13,8 @@ import Combine final class ImageFilteringContainerViewModel: ObservableObject { private var subscriptions = Set() + + var filteringViewModel = ImageFilteringViewModel() var store: AppStore? { didSet { @@ -22,9 +24,20 @@ final class ImageFilteringContainerViewModel: ObservableObject { } - // MARK: - Published Properties - @Published var hasImageWritingAuth = false + @Published var hasAuthError = false @Published var hasImageWritingError = false + + @Published var currentInputImage: UIImage? { + didSet { + filteringViewModel.inputImage = currentInputImage + } + } + + + // MARK: - Published Outputs + @Published var hasImageWritingAuth = false + @Published var isShowingErrorMessage = false + @Published var isShowingFilterView = false } @@ -42,6 +55,21 @@ extension ImageFilteringContainerViewModel { .map { $0.writingError != nil } .eraseToAnyPublisher() } + + private var isShowingFilteringViewPublisher: AnyPublisher { + $currentInputImage + .print("isShowingFilteringViewPublisher") + .map { $0 != nil } + .eraseToAnyPublisher() + } + + private var isShowingErrorMessagePublisher: AnyPublisher { + Publishers.Merge( + $hasAuthError, + $hasImageWritingError + ) + .eraseToAnyPublisher() + } } @@ -66,12 +94,6 @@ extension ImageFilteringContainerViewModel { } -// MARK: - Public Methods -extension ImageFilteringContainerViewModel { -} - - - // MARK: - Private Helpers private extension ImageFilteringContainerViewModel { @@ -86,5 +108,17 @@ private extension ImageFilteringContainerViewModel { .receive(on: DispatchQueue.main) .assign(to: \.hasImageWritingError, on: self) .store(in: &subscriptions) + + + isShowingFilteringViewPublisher + .receive(on: DispatchQueue.main) + .assign(to: \.isShowingFilterView, on: self) + .store(in: &subscriptions) + + + isShowingErrorMessagePublisher + .receive(on: DispatchQueue.main) + .assign(to: \.isShowingErrorMessage, on: self) + .store(in: &subscriptions) } } diff --git a/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringView.swift b/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringView.swift new file mode 100644 index 0000000..19de5e7 --- /dev/null +++ b/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringView.swift @@ -0,0 +1,193 @@ +// +// ImageFilteringView.swift +// Instafilter +// +// Created by CypherPoet on 11/28/19. +// ✌️ +// + +import SwiftUI + + +struct ImageFilteringView: View { + @EnvironmentObject private var store: AppStore + @ObservedObject private(set) var viewModel: ImageFilteringViewModel + + @State private var isShowingFilterSelectionSheet = false + + private let onSave: ((CGImage) -> Void) + + + init( + viewModel: ImageFilteringViewModel, + onSave: @escaping ((CGImage) -> Void) + ) { + self.viewModel = viewModel + self.onSave = onSave + } +} + + +// MARK: - Body +extension ImageFilteringView { + + var body: some View { + VStack { + // TODO: Use an alert here instead + if viewModel.filteringErrorMessage != nil { + Text(viewModel.filteringErrorMessage!) + } + + displayedImage? + .resizable() + .scaledToFit() + + Text(viewModel.currentFilter.name) + .font(.title) + .fontWeight(.light) + .padding(.vertical) + + + controls + .padding() + + Spacer() + } + .sheet(isPresented: $isShowingFilterSelectionSheet) { + FilterSelectionView( + options: CoreImageFilter.allCases, + onSelect: self.newFilterSelected(_:) + ) + } + .navigationBarTitle(Text(""), displayMode: .inline) + .navigationBarItems(leading: swapFilterButton, trailing: saveButton) + } +} + + +// MARK: - Computeds +extension ImageFilteringView { + + var outputImage: Image? { + guard let cgImage = viewModel.processedImage else { + return nil + } + + return Image(cgImage, scale: 1, label: Text("Current Image")) + } + + + var displayedImage: Image? { + if let outputImage = outputImage { + return outputImage + } + + if let inputImage = viewModel.inputImage { + return Image(uiImage: inputImage) + } + + return nil + } +} + + +// MARK: - View Variables +extension ImageFilteringView { + + private var controls: some View { +// List { + VStack { + ForEach(viewModel.activeSliders, id: \.0) { slider in + VStack(spacing: 22.0) { + VStack(spacing: 2.0) { + Text(slider.displayName) + .font(.headline) + .fontWeight(.bold) + + // Inspired by this solution for dynamically rendering controls inside + // of a `ForEach`: https://stackoverflow.com/a/56759097/8859365 + FilterSlider( + viewModel: slider.viewModel, + labelText: slider.displayName + ) + } + + Divider() + } + } + } + } + + + private var swapFilterButton: some View { + Button(action: { + self.isShowingFilterSelectionSheet = true + }) { + Image(systemName: "arrow.right.arrow.left") + Text("Swap Filter") + } + } + + + private var saveButton: some View { + Button(action: { + guard let outputImage = self.viewModel.processedImage else { fatalError() } + self.onSave(outputImage) + }) { + Text("Save") + } + .disabled(self.viewModel.processedImage == nil) + } + +} + + +// MARK: - Private Helpers +private extension ImageFilteringView { + + func newFilterSelected(_ filterOption: CoreImageFilter) { + guard let newFilter = CIFilter(name: filterOption.ciFilterName) else { + fatalError("Unable to make CIFilter from name \"\(filterOption.ciFilterName)\"") + } + + // Add a slight delay so we can see the new controls animate in after the sheet is dismissed + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.viewModel.currentFilter = newFilter + } + } +} + + + + +// MARK: - Preview +struct ImageFilteringView_Previews: PreviewProvider { + + static var previews: some View { + NavigationView { + ImageFilteringView( + viewModel: ImageFilteringViewModel( + inputImage: UIImage(named: "earth-night")! + ), + onSave: { _ in } + ) + .environmentObject(SampleStore.default) + } + } +} + + +private struct FilterSlider: View { + @ObservedObject var viewModel: ImageFilteringViewModel.SliderViewModel + + let labelText: String + + + var body: some View { + Slider( + value: $viewModel.sliderValue + ) { + Text(labelText) + } + } +} diff --git a/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringViewModel.swift b/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringViewModel.swift new file mode 100644 index 0000000..1367685 --- /dev/null +++ b/day-062/Projects/Instafilter/Instafilter/Scenes/Filtering/ImageFilteringViewModel.swift @@ -0,0 +1,164 @@ +// +// ImageFilteringViewModel.swift +// Instafilter +// +// Created by CypherPoet on 11/28/19. +// ✌️ +// + + +import SwiftUI +import Combine + + +final class ImageFilteringViewModel: ObservableObject { + private var subscriptions = Set() + private let filteringService = ImageFilteringService.shared + + private enum SliderMultiplier { + static let intensity: CGFloat = 1 + static let radius: CGFloat = 10 + static let scale: CGFloat = 200 + } + + + lazy var intensitySliderViewModel = Self.SliderViewModel() + lazy var radiusSliderViewModel = Self.SliderViewModel() + lazy var scaleSliderViewModel = Self.SliderViewModel() + + + lazy var supportedSliders: [(key: String, displayName: String, viewModel: SliderViewModel)] = [ + (kCIInputIntensityKey, "Intensity", intensitySliderViewModel), + (kCIInputRadiusKey, "Radius", radiusSliderViewModel), + (kCIInputScaleKey, "Scale", scaleSliderViewModel), + ] + + + @Published var inputImage: UIImage? + @Published var currentFilter: CIFilter + + + // MARK: - Published Outputs + @Published var filteringErrorMessage: String? + @Published var processedImage: CGImage? + + + + // MARK: - Init + init(inputImage: UIImage? = nil) { + self.inputImage = inputImage + self.currentFilter = .sepiaTone() + + setupSubscribers() + } +} + + +// MARK: - Publishers +extension ImageFilteringViewModel { + + // 📝 TODO: There's probably a good way to reduce some of the duplication here + private var intensitySliderPublisher: AnyPublisher<(filterKey: String, value: CGFloat), Never> { + intensitySliderViewModel.$sliderValue + .dropFirst() + .print("intensitySliderPublisher") + .debounce(for: .milliseconds(200), scheduler: DispatchQueue.main) + .map { (filterKey: kCIInputIntensityKey, value: $0 * SliderMultiplier.intensity) } + .eraseToAnyPublisher() + } + + private var radiusSliderPublisher: AnyPublisher<(filterKey: String, value: CGFloat), Never> { + scaleSliderViewModel.$sliderValue + .dropFirst() + .debounce(for: .milliseconds(200), scheduler: DispatchQueue.main) + .map { (filterKey: kCIInputRadiusKey, value: $0 * SliderMultiplier.radius) } + .eraseToAnyPublisher() + } + + private var scaleSliderPublisher: AnyPublisher<(filterKey: String, value: CGFloat), Never> { + scaleSliderViewModel.$sliderValue + .dropFirst() + .debounce(for: .milliseconds(200), scheduler: DispatchQueue.main) + .map { (filterKey: kCIInputScaleKey, value: $0 * SliderMultiplier.scale) } + .eraseToAnyPublisher() + } + + + var ciFilterPublisher: AnyPublisher { + Publishers.MergeMany( + intensitySliderPublisher, + scaleSliderPublisher, + radiusSliderPublisher + ) + .combineLatest($currentFilter) + .map { (sliderKeyValuePair, currentFilter) in + guard currentFilter.inputKeys.contains(sliderKeyValuePair.filterKey) else { return currentFilter } + + currentFilter.setValue(sliderKeyValuePair.value, forKey: sliderKeyValuePair.filterKey) + + return currentFilter + } + .eraseToAnyPublisher() + } + + + private var filteredCGImagePublisher: AnyPublisher { + Publishers.CompactMap(upstream: self.$inputImage) { $0 } + .combineLatest(ciFilterPublisher) + .setFailureType(to: ImageFilteringService.Error.self) + .flatMap { (image, filter) in + ImageFilteringService.shared.apply(filter, to: image) + } + .eraseToAnyPublisher() + } +} + + +// MARK: - Computeds +extension ImageFilteringViewModel { + + var activeSliders: [(key: String, displayName: String, viewModel: SliderViewModel)] { + supportedSliders.filter { + currentFilter.inputKeys.contains($0.key) + } + } +} + + +// MARK: - Private Helpers +private extension ImageFilteringViewModel { + + func setupSubscribers() { + filteredCGImagePublisher + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { completion in + switch completion { + case .failure(let error): + print("filteredImagePublisher error") + switch error { + case .cgImage(let message), + .ciImage(let message), + .filtering(let message): + self.filteringErrorMessage = message + } + case .finished: + print("filteredImagePublisher finished") + } + }, + receiveValue: { + print("filteredImagePublisher, received value: \($0)") + self.processedImage = $0 + } + ) + .store(in: &subscriptions) + } +} + + +extension ImageFilteringViewModel { + + final class SliderViewModel: ObservableObject { + @Published var sliderValue: CGFloat = 0.0 + } +} diff --git a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringView.swift b/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringView.swift deleted file mode 100644 index ebfed16..0000000 --- a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringView.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// ImageFilteringView.swift -// Instafilter -// -// Created by CypherPoet on 11/28/19. -// ✌️ -// - -import SwiftUI - - -struct ImageFilteringView: View { - @EnvironmentObject private var store: AppStore - @ObservedObject private(set) var viewModel: ImageFilteringViewModel - - private let inputImage: UIImage - - - init( - inputImage: UIImage, - store: AppStore - ) { - self.inputImage = inputImage - - self.viewModel = ImageFilteringViewModel( - inputImage: inputImage, - store: store - ) - } -} - - -// MARK: - Body -extension ImageFilteringView { - - var body: some View { - VStack { - // TODO: Use an alert here instead - if viewModel.filteringErrorMessage != nil { - Text(viewModel.filteringErrorMessage!) - } - - (outputImage ?? Image(uiImage: inputImage)) - .resizable() - .scaledToFit() - - controls - .padding() - .padding(.top) - - Spacer() - } - } -} - - -// MARK: - Computeds -extension ImageFilteringView { - - var outputImage: Image? { - guard let cgImage = store.state.filteredImages.filteredOutputCGImage else { - return nil - } - - return Image(uiImage: UIImage(cgImage: cgImage)) - } - -} - - -// MARK: - View Variables -extension ImageFilteringView { - - private var controls: some View { - VStack(spacing: 24) { - - VStack(spacing: 2.0) { - Text("Intensity") - .font(.headline) - .fontWeight(.bold) - - Slider( - value: $viewModel.filterIntensity, - minimumValueLabel: Text("0"), - maximumValueLabel: Text("1") - ) { - Text("Intensity") - } - } - } - } -} - - - -// MARK: - Preview -struct ImageFilteringView_Previews: PreviewProvider { - - static var previews: some View { - NavigationView { - ImageFilteringView( - inputImage: UIImage(named: "earth-night")!, - store: SampleStore.default - ) - .environmentObject(SampleStore.default) - } - } -} diff --git a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringViewModel.swift b/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringViewModel.swift deleted file mode 100644 index 42473da..0000000 --- a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringViewModel.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// ImageFilteringViewModel.swift -// Instafilter -// -// Created by CypherPoet on 11/28/19. -// ✌️ -// - - -import SwiftUI -import Combine - - -final class ImageFilteringViewModel: ObservableObject { - private var subscriptions = Set() - - private let filteringService = ImageFilteringService.shared - private let store: AppStore - - @Published var inputImage: UIImage - @Published var currentFilter: CIFilter - @Published var filterIntensity: CGFloat = 0.0 - - - // MARK: - Published Properties -// @Published var filteredImage: Image? - @Published var filteringErrorMessage: String? - - - - // MARK: - Init - init( - inputImage: UIImage, - store: AppStore - ) { - self.inputImage = inputImage - self.store = store - self.currentFilter = .sepiaTone() - - setupSubscribers() - } -} - - -// MARK: - Publishers -extension ImageFilteringViewModel { - - private var filterIntensityPublisher: AnyPublisher { - $filterIntensity - .debounce(for: .milliseconds(200), scheduler: DispatchQueue.main) - .eraseToAnyPublisher() - } - - - // TODO: Should this create a copy and return it? Should this be used straight-on - // instead of the other `currentFilter` property? - var ciFilterPublisher: AnyPublisher { -// Publishers.CombineLatest(filterIntensityPublisher, ) - filterIntensityPublisher - .map { - self.currentFilter.setValue($0, forKey: kCIInputIntensityKey) - - return self.currentFilter - } - .eraseToAnyPublisher() - } - - - private var filteredCGImagePublisher: AnyPublisher { - ciFilterPublisher - .setFailureType(to: ImageFilteringService.Error.self) - .flatMap { filter in - ImageFilteringService.shared.apply(filter, to: self.inputImage) - } - .eraseToAnyPublisher() - } -} - - -// MARK: - Computeds -extension ImageFilteringViewModel { -} - - -// MARK: - Public Methods -extension ImageFilteringViewModel { -} - - - -// MARK: - Private Helpers -private extension ImageFilteringViewModel { - - func setupSubscribers() { - - filteredCGImagePublisher - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { completion in - switch completion { - case .failure(let error): - print("filteredImagePublisher error") - switch error { - case .cgImage(let message), - .ciImage(let message), - .filtering(let message): - self.filteringErrorMessage = message - } - case .finished: - print("filteredImagePublisher finished") - } - }, - receiveValue: { - self.store.send(.filteredImages(.setFilteredOutput(image: $0))) - } - ) - .store(in: &subscriptions) - } -}