Create some image writing utilities and refactor app data flow.

This commit is contained in:
CypherPoet 2019-11-30 19:53:24 -06:00
parent ded8dbda5c
commit b10502a91d
13 changed files with 502 additions and 85 deletions

View File

@ -12,6 +12,11 @@
F32995322390C29100D2D963 /* ImageFilteringContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */; };
F3299535239181A900D2D963 /* UIImagePickerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3299534239181A900D2D963 /* UIImagePickerWrapper.swift */; };
F329953723919B6700D2D963 /* UIImagePickerWrapper+Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F329953623919B6700D2D963 /* UIImagePickerWrapper+Coordinator.swift */; };
F329953A2391EEE800D2D963 /* FilteredImagesState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32995392391EEE800D2D963 /* FilteredImagesState.swift */; };
F329953D2391F1D000D2D963 /* SampleData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F329953C2391F1D000D2D963 /* SampleData.swift */; };
F329953F23920AE800D2D963 /* ImageWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F329953E23920AE800D2D963 /* ImageWriter.swift */; };
F32995412393025000D2D963 /* ImageWritingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32995402393025000D2D963 /* ImageWritingState.swift */; };
F32995432393199B00D2D963 /* ImageFilteringContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32995422393199B00D2D963 /* ImageFilteringContainerViewModel.swift */; };
F352424E238F3F18009DF1F9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F352424D238F3F18009DF1F9 /* AppDelegate.swift */; };
F3524250238F3F18009DF1F9 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F352424F238F3F18009DF1F9 /* SceneDelegate.swift */; };
F3524254238F3F19009DF1F9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F3524253238F3F19009DF1F9 /* Assets.xcassets */; };
@ -28,6 +33,11 @@
F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFilteringContainerView.swift; sourceTree = "<group>"; };
F3299534239181A900D2D963 /* UIImagePickerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImagePickerWrapper.swift; sourceTree = "<group>"; };
F329953623919B6700D2D963 /* UIImagePickerWrapper+Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImagePickerWrapper+Coordinator.swift"; sourceTree = "<group>"; };
F32995392391EEE800D2D963 /* FilteredImagesState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilteredImagesState.swift; sourceTree = "<group>"; };
F329953C2391F1D000D2D963 /* SampleData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleData.swift; sourceTree = "<group>"; };
F329953E23920AE800D2D963 /* ImageWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWriter.swift; sourceTree = "<group>"; };
F32995402393025000D2D963 /* ImageWritingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWritingState.swift; sourceTree = "<group>"; };
F32995422393199B00D2D963 /* ImageFilteringContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFilteringContainerViewModel.swift; sourceTree = "<group>"; };
F352424A238F3F18009DF1F9 /* Instafilter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Instafilter.app; sourceTree = BUILT_PRODUCTS_DIR; };
F352424D238F3F18009DF1F9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
F352424F238F3F18009DF1F9 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
@ -68,6 +78,14 @@
path = UIImagePickerWrapper;
sourceTree = "<group>";
};
F329953B2391F1C300D2D963 /* Sample Data */ = {
isa = PBXGroup;
children = (
F329953C2391F1D000D2D963 /* SampleData.swift */,
);
path = "Sample Data";
sourceTree = "<group>";
};
F3524241238F3F18009DF1F9 = {
isa = PBXGroup;
children = (
@ -103,6 +121,7 @@
F3524255238F3F19009DF1F9 /* Preview Content */ = {
isa = PBXGroup;
children = (
F329953B2391F1C300D2D963 /* Sample Data */,
F3524256238F3F19009DF1F9 /* Preview Assets.xcassets */,
);
path = "Preview Content";
@ -113,6 +132,7 @@
children = (
F329952D23908BD400D2D963 /* ImageFilteringView.swift */,
F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */,
F32995422393199B00D2D963 /* ImageFilteringContainerViewModel.swift */,
F329952F23908CB500D2D963 /* ImageFilteringViewModel.swift */,
);
path = Scenes;
@ -123,6 +143,7 @@
children = (
F32995332391817800D2D963 /* Views */,
F3F566AE238F6556009E1FB0 /* ImageFilteringService.swift */,
F329953E23920AE800D2D963 /* ImageWriter.swift */,
);
path = Reusables;
sourceTree = "<group>";
@ -147,6 +168,8 @@
isa = PBXGroup;
children = (
F3F566B5238F6A48009E1FB0 /* AppState.swift */,
F32995392391EEE800D2D963 /* FilteredImagesState.swift */,
F32995402393025000D2D963 /* ImageWritingState.swift */,
);
path = State;
sourceTree = "<group>";
@ -228,13 +251,18 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F329953A2391EEE800D2D963 /* FilteredImagesState.swift in Sources */,
F3F566AF238F6556009E1FB0 /* ImageFilteringService.swift in Sources */,
F329953723919B6700D2D963 /* UIImagePickerWrapper+Coordinator.swift in Sources */,
F329952E23908BD400D2D963 /* ImageFilteringView.swift in Sources */,
F32995412393025000D2D963 /* ImageWritingState.swift in Sources */,
F329953F23920AE800D2D963 /* ImageWriter.swift in Sources */,
F32995322390C29100D2D963 /* ImageFilteringContainerView.swift in Sources */,
F32995432393199B00D2D963 /* ImageFilteringContainerViewModel.swift in Sources */,
F352424E238F3F18009DF1F9 /* AppDelegate.swift in Sources */,
F329953023908CB500D2D963 /* ImageFilteringViewModel.swift in Sources */,
F3299535239181A900D2D963 /* UIImagePickerWrapper.swift in Sources */,
F329953D2391F1D000D2D963 /* SampleData.swift in Sources */,
F3524250238F3F18009DF1F9 /* SceneDelegate.swift in Sources */,
F3F566B6238F6A48009E1FB0 /* AppState.swift in Sources */,
);

View File

@ -10,4 +10,30 @@ import Foundation
import CypherPoetSwiftUIKit_DataFlowUtils
struct AppState {
var filteredImages = FilteredImagesState()
var imageWriting = ImageWritingState()
}
enum AppAction {
case filteredImages(_ filteredImagesAction: FilteredImagesAction)
case imageWriting(_ imageWritingAction: ImageWritingAction)
}
//enum AppSideEffect: SideEffect {}
// MARK: - Reducer
let appReducer = Reducer<AppState, AppAction> { appState, action in
switch action {
case let .filteredImages(action):
filteredImagesReducer.reduce(&appState.filteredImages, action)
case let .imageWriting(action):
imageWritingReducer.reduce(&appState.imageWriting, action)
}
}
typealias AppStore = Store<AppState, AppAction>

View File

@ -0,0 +1,61 @@
//
// FilteredImagesState.swift
// Instafilter
//
// Created by CypherPoet on 11/29/19.
//
//
import Foundation
import UIKit
import Combine
import CypherPoetSwiftUIKit_DataFlowUtils
struct FilteredImagesState {
var currentInputImage: UIImage?
var filteredOutputCGImage: CGImage?
var filteringErrorMessage: String?
}
enum FilteredImagesSideEffect: SideEffect {
case apply(filter: CIFilter, to: UIImage)
func mapToAction() -> AnyPublisher<AppAction, Never> {
switch self {
case let .apply(filter, inputImage):
return ImageFilteringService.shared
.apply(filter, to: inputImage)
.map { cgImage in
AppAction.filteredImages(.setFilteredOutput(image: cgImage))
}
.catch { (error: ImageFilteringService.Error) in
// TODO: Better handling here 🙈
Just(AppAction.filteredImages(.setFilteringError(message: "Failed")))
}
.eraseToAnyPublisher()
}
}
}
enum FilteredImagesAction {
case setCurrentInput(image: UIImage)
case setFilteredOutput(image: CGImage)
case setFilteringError(message: String)
}
// MARK: - Reducer
let filteredImagesReducer = Reducer<FilteredImagesState, FilteredImagesAction> { state, action in
switch action {
case .setCurrentInput(let image):
state.currentInputImage = image
case .setFilteredOutput(let image):
state.filteredOutputCGImage = image
case .setFilteringError(let message):
state.filteringErrorMessage = message
}
}

View File

@ -0,0 +1,59 @@
//
// ImageWritingState.swift
// Instafilter
//
// Created by CypherPoet on 11/30/19.
//
//
import Foundation
import UIKit
import Combine
import CypherPoetSwiftUIKit_DataFlowUtils
struct ImageWritingState {
var hasWritingError: Bool { writingError != nil }
var writingError: ImageWriter.Error?
}
enum ImageWritingSideEffect: SideEffect {
case saveOutput(image: CGImage)
func mapToAction() -> AnyPublisher<AppAction, Never> {
switch self {
case let .saveOutput(cgImage):
let imageToSave = UIImage(cgImage: cgImage)
return ImageWriter
.write(imageToPhotoAlbum: imageToSave)
.map { _ in AppAction.imageWriting(.setWritingSuccess) }
.catch({ (imageWriterError) in
Just(AppAction.imageWriting(.setWritingError(imageWriterError)))
})
.eraseToAnyPublisher()
}
}
}
enum ImageWritingAction {
case setWritingError(ImageWriter.Error)
case setWritingSuccess
}
// MARK: - Reducer
let imageWritingReducer = Reducer<ImageWritingState, ImageWritingAction> { state, action in
switch action {
case let .setWritingError(error):
state.writingError = error
case .setWritingSuccess:
state.writingError = nil
}
}

View File

@ -20,6 +20,10 @@
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This application would like to be able to save images to your Photos Album.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This application would like to be able to read and load images from your Photos Album. These can then be used to apply image filtering.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@ -0,0 +1,19 @@
//
// SampleData.swift
// Instafilter
//
// Created by CypherPoet on 11/29/19.
//
//
#if DEBUG
import Foundation
enum SampleStore {
static let `default` = AppStore(initialState: AppState(), appReducer: appReducer)
}
#endif

View File

@ -103,9 +103,6 @@ extension ImageFilteringService {
.flatMap { ciImage in
self.createCGImage(from: ciImage)
}
// .map(createCGImage(from:))
// .switchToLatest()
// .map({ $0 })
.eraseToAnyPublisher()
}
}

View File

@ -0,0 +1,89 @@
//
// ImageWriter.swift
// Instafilter
//
// Created by CypherPoet on 11/29/19.
//
//
import Foundation
import UIKit
import Combine
import Photos
enum ImageWriter {
static let defaultQueue = DispatchQueue(label: "Image Writer", qos: .userInitiated)
enum Error: Swift.Error, Identifiable {
var id: String { self.localizedDescription }
case failedToCreateAsset
case genericWritingError(Swift.Error)
}
static func write(
imageToPhotoAlbum image: UIImage,
on queue: DispatchQueue = defaultQueue
) -> Future<String, ImageWriter.Error> {
Future { resolve in
queue.async {
do {
try PHPhotoLibrary.shared().performChangesAndWait {
let creationRequest = PHAssetCreationRequest.creationRequestForAsset(from: image)
guard let savedAssetID = creationRequest.placeholderForCreatedAsset?.localIdentifier else {
return resolve(.failure(.failedToCreateAsset))
}
resolve(.success(savedAssetID))
}
} catch {
resolve(.failure(.genericWritingError(error)))
}
}
}
}
}
extension ImageWriter {
static var isAuthorized: Future<Bool, Never> {
Future { resolve in
PHPhotoLibrary.requestAuthorization({ authStatus in
switch authStatus {
case .authorized:
resolve(.success(true))
case .notDetermined,
.restricted,
.denied:
resolve(.success(false))
@unknown default:
resolve(.success(false))
}
})
}
}
}
// TODO: This might not be needed... sort of just experimenting here 🙂
extension PHPhotoLibrary {
static func fetchAuthStatus(then completionHandler: @escaping (Bool) -> Void) {
// If authozied, callback immediately.
guard authorizationStatus() != .authorized else {
return completionHandler(true)
}
requestAuthorization { (status) in
completionHandler(status == .authorized)
}
}
}

View File

@ -23,10 +23,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let store = AppStore(initialState: AppState(), appReducer: appReducer)
// Create the SwiftUI view that provides the window contents.
let entryView = ImageFilteringContainerView()
.accentColor(.pink)
.environmentObject(store)
window.rootViewController = UIHostingController(rootView: entryView)

View File

@ -7,11 +7,17 @@
//
import SwiftUI
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
}
@ -19,6 +25,7 @@ struct ImageFilteringContainerView: View {
extension ImageFilteringContainerView {
var body: some View {
NavigationView {
VStack(spacing: 42.0) {
Spacer()
@ -29,25 +36,34 @@ extension ImageFilteringContainerView {
Spacer()
}
.navigationBarTitle("📸 Instafilter")
.navigationBarItems(trailing: saveButton)
}
.onAppear { self.viewModel.store = self.store }
.sheet(isPresented: $isShowingImagePicker) {
UIImagePickerWrapper(selectedImage: self.$currentInputImage)
}
.alert(isPresented: $isShowingWritingAuthError) { self.imageWritingAuthErrorAlert }
.alert(isPresented: $viewModel.hasImageWritingError) { self.imageWritingErrorAlert }
}
}
// MARK: - Computeds
extension ImageFilteringContainerView {
var filteredImagesState: FilteredImagesState { store.state.filteredImages }
var filteredOutputCGImage: CGImage? { filteredImagesState.filteredOutputCGImage }
}
// MARK: - View Variables
extension ImageFilteringContainerView {
private var imageContent: some View {
Group {
if currentInputImage != nil {
ImageFilteringView(inputImage: currentInputImage!)
ImageFilteringView(inputImage: currentInputImage!, store: store)
} else {
Text("Select an Image to begin filtering.")
.font(.title)
@ -71,11 +87,51 @@ extension ImageFilteringContainerView {
.cornerRadius(12)
.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")
}
.disabled(filteredOutputCGImage == nil)
}
private var imageWritingErrorAlert: Alert {
Alert(
title: Text("Failed to save image."),
message: Text(self.viewModel.imageWritingErrorMessage),
dismissButton: .default(Text("OK"))
)
}
private var imageWritingAuthErrorAlert: Alert {
Alert(
title: Text("Unable to save images."),
message: Text(self.viewModel.imageWritingAuthErrorMessage),
dismissButton: .default(Text("OK"))
)
}
}
// MARK: - Private Helpers
private extension ImageFilteringContainerView {
func saveFilteredImage() {
guard let filteredCGImage = filteredOutputCGImage else {
preconditionFailure("No output image available for saving")
}
store.send(ImageWritingSideEffect.saveOutput(image: filteredCGImage))
}
}
@ -84,5 +140,6 @@ struct ImageFilteringContainerView_Previews: PreviewProvider {
static var previews: some View {
ImageFilteringContainerView()
.environmentObject(SampleStore.default)
}
}

View File

@ -0,0 +1,90 @@
//
// ImageFilteringContainerViewModel.swift
// Instafilter
//
// Created by CypherPoet on 11/30/19.
//
//
import SwiftUI
import Combine
final class ImageFilteringContainerViewModel: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
var store: AppStore? {
didSet {
guard store != nil else { return }
self.setupSubscribers()
}
}
// MARK: - Published Properties
@Published var hasImageWritingAuth = false
@Published var hasImageWritingError = false
}
// MARK: - Publishers
extension ImageFilteringContainerViewModel {
private var imageWritingStatePublisher: AnyPublisher<ImageWritingState, Never>? {
store?.$state
.map(\.imageWriting)
.eraseToAnyPublisher()
}
private var hasImageWritingErrorPublisher: AnyPublisher<Bool, Never>? {
imageWritingStatePublisher?
.map { $0.writingError != nil }
.eraseToAnyPublisher()
}
}
// MARK: - Computeds
extension ImageFilteringContainerViewModel {
var imageWritingAuthErrorMessage: String {
guard !hasImageWritingAuth else { return "" }
return """
This app doesn't have permission to write images to your Photos album.
You can grant permission from within the Settings app.
"""
}
var imageWritingErrorMessage: String {
guard !hasImageWritingError else { return "" }
return "Image writing operation failed."
}
}
// MARK: - Public Methods
extension ImageFilteringContainerViewModel {
}
// MARK: - Private Helpers
private extension ImageFilteringContainerViewModel {
func setupSubscribers() {
ImageWriter.isAuthorized
.receive(on: DispatchQueue.main)
.assign(to: \.hasImageWritingAuth, on: self)
.store(in: &subscriptions)
hasImageWritingErrorPublisher?
.receive(on: DispatchQueue.main)
.assign(to: \.hasImageWritingError, on: self)
.store(in: &subscriptions)
}
}

View File

@ -12,10 +12,14 @@ import SwiftUI
struct ImageFilteringView: View {
@ObservedObject private(set) var viewModel: ImageFilteringViewModel
// @ObservedObject private(set) var viewModel = ImageFilteringViewModel()
init(inputImage: UIImage) {
self.viewModel = ImageFilteringViewModel(inputImage: inputImage)
init(
inputImage: UIImage,
store: AppStore
) {
self.viewModel = ImageFilteringViewModel(
inputImage: inputImage,
store: store
)
}
}
@ -25,17 +29,15 @@ extension ImageFilteringView {
var body: some View {
VStack {
// TODO: Use an alert here instead
if viewModel.filteringErrorMessage != nil {
Text(viewModel.filteringErrorMessage!)
}
viewModel.filteredImage?
(viewModel.filteredImage ?? Image(uiImage: viewModel.inputImage))
.resizable()
.scaledToFit()
}
// .onAppear {
// self.viewModel.inputImage = UIImage(named: "earth-night")
// }
}
}
@ -60,7 +62,8 @@ struct ImageFilteringView_Previews: PreviewProvider {
static var previews: some View {
ImageFilteringView(
inputImage: UIImage(named: "earth-night")!
inputImage: UIImage(named: "earth-night")!,
store: SampleStore.default
)
}
}

View File

@ -14,7 +14,8 @@ import Combine
final class ImageFilteringViewModel: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
private var filteringService = ImageFilteringService.shared
private let filteringService = ImageFilteringService.shared
private let store: AppStore
@Published var inputImage: UIImage
@Published var currentFilter: CIFilter
@ -27,8 +28,12 @@ final class ImageFilteringViewModel: ObservableObject {
// MARK: - Init
init(inputImage: UIImage) {
init(
inputImage: UIImage,
store: AppStore
) {
self.inputImage = inputImage
self.store = store
self.currentFilter = .sepiaTone()
setupSubscribers()
@ -39,34 +44,28 @@ final class ImageFilteringViewModel: ObservableObject {
// MARK: - Publishers
extension ImageFilteringViewModel {
private var filteredImagePublisher: AnyPublisher<Image, ImageFilteringService.Error> {
$inputImage
.print("filteredImagePublisher")
.compactMap { $0 }
.setFailureType(to: ImageFilteringService.Error.self)
.map { inputImage in
self.filteringService.apply(self.currentFilter, to: inputImage)
}
.switchToLatest()
.map { cgImage in
print("filteredImagePublisher mapping cgImage")
return Image(uiImage: UIImage(cgImage: cgImage))
}
// .catch { error in
// switch error {
// case .cgImage(let message),
// .ciImage(let message),
// .filtering(let message):
// self.filteringErrorMessage = message
// }
//
// return Just(nil).eraseToAnyPublisher()
// }
private var filteredImagesStatePublisher: AnyPublisher<FilteredImagesState, Never> {
store.$state
.map(\.filteredImages)
.eraseToAnyPublisher()
}
private var filteredCGImagePublisher: AnyPublisher<CGImage, Never> {
filteredImagesStatePublisher
.map(\.filteredOutputCGImage)
.compactMap { $0 }
.eraseToAnyPublisher()
}
private var filteredImagePublisher: AnyPublisher<Image?, Never> {
filteredCGImagePublisher
.map { cgImage in
Image(uiImage: UIImage(cgImage: cgImage))
}
.eraseToAnyPublisher()
}
}
@ -87,7 +86,14 @@ private extension ImageFilteringViewModel {
func setupSubscribers() {
filteredImagePublisher
.receive(on: DispatchQueue.main)
// .handleEvents(receiveCompletion: { completion in
.assign(to: \.filteredImage, on: self)
.store(in: &subscriptions)
// filteredImagePublisher
// .receive(on: DispatchQueue.main)
// .sink(
// receiveCompletion: { completion in
// switch completion {
// case .failure(let error):
// print("filteredImagePublisher error")
@ -100,33 +106,9 @@ private extension ImageFilteringViewModel {
// case .finished:
// print("filteredImagePublisher finished")
// }
// })
.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.filteredImage = $0 }
)
.store(in: &subscriptions)
// filteredImagePublisher
// .replaceError(with: nil)
// .compactMap( { $0 })
// .receive(on: DispatchQueue.main)
// .assign(to: \.filteredImage, on: self)
// },
// receiveValue: { self.filteredImage = $0 }
// )
// .store(in: &subscriptions)
}
}