Implement changes for Day 66
This commit is contained in:
parent
c0799e1c33
commit
0c5a8464fe
|
@ -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 = "<group>"; };
|
||||
F3524259238F3F19009DF1F9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
F352425B238F3F19009DF1F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
F36D28E123962F8B00095B66 /* CoreImageFilterNames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreImageFilterNames.swift; sourceTree = "<group>"; };
|
||||
F3F566AE238F6556009E1FB0 /* ImageFilteringService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFilteringService.swift; sourceTree = "<group>"; };
|
||||
F3F566B5238F6A48009E1FB0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||
F3F656E72395CC44007AC32E /* FilterSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterSelectionView.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
|
@ -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 = "<group>";
|
||||
};
|
||||
F36D28E023962F7700095B66 /* Constants */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F36D28E123962F8B00095B66 /* CoreImageFilterNames.swift */,
|
||||
);
|
||||
path = Constants;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F3F566B3238F6A39009E1FB0 /* Data */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -174,6 +184,18 @@
|
|||
path = State;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F3F656E52395CBFF007AC32E /* Filtering */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F329952D23908BD400D2D963 /* ImageFilteringView.swift */,
|
||||
F3F656E72395CC44007AC32E /* FilterSelectionView.swift */,
|
||||
F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */,
|
||||
F32995422393199B00D2D963 /* ImageFilteringContainerViewModel.swift */,
|
||||
F329952F23908CB500D2D963 /* ImageFilteringViewModel.swift */,
|
||||
);
|
||||
path = Filtering;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */,
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ import Combine
|
|||
|
||||
final class ImageFilteringContainerViewModel: ObservableObject {
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
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<Bool, Never> {
|
||||
$currentInputImage
|
||||
.print("isShowingFilteringViewPublisher")
|
||||
.map { $0 != nil }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private var isShowingErrorMessagePublisher: AnyPublisher<Bool, Never> {
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
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<CIFilter, Never> {
|
||||
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<CGImage, ImageFilteringService.Error> {
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
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<CGFloat, Never> {
|
||||
$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<CIFilter, Never> {
|
||||
// Publishers.CombineLatest(filterIntensityPublisher, )
|
||||
filterIntensityPublisher
|
||||
.map {
|
||||
self.currentFilter.setValue($0, forKey: kCIInputIntensityKey)
|
||||
|
||||
return self.currentFilter
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
||||
private var filteredCGImagePublisher: AnyPublisher<CGImage, ImageFilteringService.Error> {
|
||||
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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue