Implement changes for Day 66

This commit is contained in:
CypherPoet 2019-12-04 03:38:18 -06:00
parent c0799e1c33
commit 0c5a8464fe
11 changed files with 608 additions and 274 deletions

View File

@ -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 */,

View File

@ -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"
}
}
}

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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 }
)
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}