From 770182cd6cfd47094dfb4855629f5729933b629d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ates=CC=A7=20=EF=A3=BF?= Date: Sun, 5 Nov 2023 17:29:04 +0300 Subject: [PATCH] Huge refactoring --- Package.swift | 4 + .../ErrorableViewModelProtocol.swift | 15 - .../ErrorableView/ErrorableViewProtocol.swift | 457 +++++++++++++----- Sources/ErrorableView/PageStates.swift | 12 - 4 files changed, 332 insertions(+), 156 deletions(-) delete mode 100644 Sources/ErrorableView/ErrorableViewModelProtocol.swift delete mode 100644 Sources/ErrorableView/PageStates.swift diff --git a/Package.swift b/Package.swift index 790aad4..aab6f5b 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,10 @@ import PackageDescription let package = Package( name: "ErrorableView", + platforms: [ + .iOS(.v14), + .macOS(.v12) + ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( diff --git a/Sources/ErrorableView/ErrorableViewModelProtocol.swift b/Sources/ErrorableView/ErrorableViewModelProtocol.swift deleted file mode 100644 index 479e0c9..0000000 --- a/Sources/ErrorableView/ErrorableViewModelProtocol.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// ErrorableViewModelProtocol.swift -// -// -// Created by Mehmet Ateş on 26.08.2023. -// - -import Combine - -@available(macOS 10.15, *) -@available(iOS 13.0, *) -public protocol ErrorableViewModelProtocol: AnyObject, ObservableObject { - var state: PageStates { get set } - func refresh() -} diff --git a/Sources/ErrorableView/ErrorableViewProtocol.swift b/Sources/ErrorableView/ErrorableViewProtocol.swift index a9a660a..b61e72e 100644 --- a/Sources/ErrorableView/ErrorableViewProtocol.swift +++ b/Sources/ErrorableView/ErrorableViewProtocol.swift @@ -7,174 +7,373 @@ import SwiftUI -@available(macOS 11.0, *) -@available(iOS 14.0, *) -public protocol ErrorableViewProtocol: View where ViewModel: ErrorableViewModelProtocol { +/** + A protocol that defines the requirements for a view that can display content and handle error states using a view model. + + Conforming to the `ErrorableViewProtocol` allows a view to display both regular content and error states using a specified view model and error state configuration model. + + - Parameters: + - ViewModel: A generic associated type representing the view model associated with the view. + - Content: A generic associated type representing the content that can be displayed within the view. + + This protocol requires the conforming view to provide a content view using a `ViewBuilder`, a view model, and an error state configuration model. It is typically used to create views that can gracefully handle and display errors while presenting content. + + - Note: Conforming Other Protocols to ErrorableViewProtocol + + In some cases, you may want to create more specific protocols that conform to the ErrorableViewProtocol. Two such protocols are ErrorableView and ErrorableSheetView. When these protocols conform to ErrorableViewProtocol, it's essential to consider their unique use cases and requirements. + + - **ErrorableView Protocol**: + The ErrorableView protocol extends the ErrorableViewProtocol and specializes it for specific scenarios, adding further requirements or custom behavior tailored to your needs. You should use the ErrorableView protocol when you require a more specialized view that can handle errors and content in a way that aligns with your project's requirements. + - **ErrorableSheetView Protocol**: + The ErrorableSheetView protocol is designed to work with SwiftUI's Sheet presentation style. It allows you to create views that can display content and handle error states when presented as a sheet. By conforming to the ErrorableViewProtocol, you can ensure consistency in error handling across different parts of your app that utilize sheet presentations. + + Extension for the `ErrorableViewProtocol` protocol providing a default error state configuration model. + + This extension offers a default error state configuration for views conforming to the `ErrorableViewProtocol`. The error state configuration includes a title, subtitle, system image name, button title, and associated actions to handle the error state. + + - Returns: + An instance of `ErrorStateConfigureModel` with the following default values: + - Title: "Error!" + - Subtitle: "We encountered an error. Please try again later!" + - System Image Name: "exclamationmark.triangle" + - Button Title: "Try Again!" + - Button Action: When the "Try Again!" button is tapped, it sets the associated view model's `pageState` to `.loading`. + - Dismiss Action: When the error state is dismissed, it also sets the associated view model's `pageState` to `.loading`. + + This default error state configuration simplifies the handling of error states for views conforming to the `ErrorableViewProtocol`. Conforming views can use this default configuration or provide their custom error state configuration by implementing the `errorStateConfigModel` property in their own way. + + ### Example Usage: + ```swift + @available(iOS 15.0, *) + private struct SimpleExampleView: ErrorableView { + typealias ViewModel = ExampleViewModel + @ObservedObject var viewModel: ExampleViewModel = ExampleViewModel() + + var content: some View { + VStack { + Text("Loaded Statement!") + } + } + } + ``` + - Note: + This default configuration is a convenient starting point and ensures a consistent error state representation across views that conform to the ErrorableViewProtocol. However, it can be overridden or customized as needed when defining specific views. + - SeeAlso: `ErrorableViewProtocol`, `ErrorStateConfigureModel` + */ +public protocol ErrorableViewProtocol: View where Body: View, ViewModel: ErrorableBaseViewModel { associatedtype ViewModel associatedtype Content - var viewModel: ViewModel { get set } + /// A view builder for the content to be displayed within the view. + @ViewBuilder var content: Self.Content { get } + + /// The view model associated with the view. + var viewModel: ViewModel { get } + + /// The configuration model for error state handling within the view. + var errorStateConfigModel: ErrorStateConfigureModel { get } } -@available(macOS 11.0, *) -@available(iOS 14.0, *) -public extension ErrorableViewProtocol where Content == AnyView { - func createErrorableView( - @ViewBuilder loadingView: () -> Content, - @ViewBuilder failureView: () -> Content, - @ViewBuilder successfulView: () -> Content - ) -> Content { - switch viewModel.state { - case .loading: - return loadingView() - case .successful: - return successfulView() - case .failure: - return failureView() - } +public extension ErrorableViewProtocol { + var errorStateConfigModel: ErrorStateConfigureModel { + ErrorStateConfigureModel.Builder() + .title("Error!") + .subtitle("We encountered an error.\n Please try again later!") + .systemName("exclamationmark.triangle") + .buttonTitle("Try Again!") + .buttonAction { + viewModel.pageState = .loading + }.dismissAction { + viewModel.pageState = .loading + }.build() } +} + +public class ErrorableBaseViewModel: ObservableObject { + @Published var pageState: PageStates = .loading +} - func createErrorableView( - @ViewBuilder failureView: () -> Content, - @ViewBuilder successfulView: () -> Content - ) -> Content { - switch viewModel.state { - case .loading: - return loadingState - case .successful: - return successfulView() - case .failure: - return failureView() +public protocol ErrorableSheetView: ErrorableViewProtocol {} + +public extension ErrorableSheetView where Content: View { + var body: some View { + VStack { + if viewModel.pageState == .loading { + LoadingStateView() + } else if viewModel.pageState == .successful { + content + } + }.sheet( + isPresented: .constant(viewModel.pageState == .failure), + onDismiss: errorStateConfigModel.dismissAction + ) { + NavigationView { + ErrorStateView(model: errorStateConfigModel) + .toolbar { + Button { + errorStateConfigModel.buttonAction?() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + }.accentColor(.secondary) + } + } } } +} + +public protocol ErrorableView: ErrorableViewProtocol {} - func createErrorableView( - errorTitle: LocalizedStringKey, - errorSubTitle: LocalizedStringKey? = nil, - errorIcon: String? = nil, - errorSystemIcon: String? = nil, - errorButtonTitle: LocalizedStringKey, - @ViewBuilder successfulView: () -> Content - ) -> Content { - switch viewModel.state { - case .loading: - return loadingState - case .successful: - return successfulView() - case .failure: - return failuteState(errorTitle: errorTitle, errorSubTitle: errorSubTitle, errorIcon: errorIcon, errorSystemIcon: errorSystemIcon, errorButtonTitle: errorButtonTitle) +public extension ErrorableView where Content: View { + var body: some View { + VStack { + if viewModel.pageState == .loading { + LoadingStateView() + } else if viewModel.pageState == .failure { + ErrorStateView(model: errorStateConfigModel) + } else { + content + } } } +} + +@frozen public struct ErrorStateConfigureModel { + var title: LocalizedStringKey + var subtitle: LocalizedStringKey? + var icon: String? + var systemName: String? + var buttonTitle: LocalizedStringKey? + var dismissAction: (() -> Void)? + var buttonAction: (() -> Void)? - private var loadingState: Content { - AnyView( - VStack { - Spacer() - ProgressView() - .accentColor(.accentColor) - Spacer() - } - ) + public class Builder { + private var title: LocalizedStringKey = "Error!" + private var subtitle: LocalizedStringKey? + private var icon: String? + private var systemName: String? + private var buttonTitle: LocalizedStringKey? + private var buttonAction: (() -> Void)? + private var dismissAction: (() -> Void)? + + @discardableResult + func title(_ title: LocalizedStringKey) -> Self { + self.title = title + return self + } + + @discardableResult + func subtitle(_ subtitle: LocalizedStringKey?) -> Self { + self.subtitle = subtitle + return self + } + + @discardableResult + func icon(_ icon: String?) -> Self { + self.icon = icon + return self + } + + @discardableResult + func systemName(_ systemName: String?) -> Self { + self.systemName = systemName + return self + } + + @discardableResult + func buttonTitle(_ buttonTitle: LocalizedStringKey?) -> Self { + self.buttonTitle = buttonTitle + return self + } + + @discardableResult + func dismissAction(_ dismissAction: (() -> Void)?) -> Self { + self.dismissAction = dismissAction + return self + } + + @discardableResult + func buttonAction(_ buttonAction: (() -> Void)?) -> Self { + self.buttonAction = buttonAction + return self + } + + func build() -> ErrorStateConfigureModel { + ErrorStateConfigureModel( + title: title, + subtitle: subtitle, + icon: icon, + systemName: systemName, + buttonTitle: buttonTitle, + dismissAction: dismissAction, + buttonAction: buttonAction + ) + } } +} + +@frozen public struct ErrorStateView: View { + var model: ErrorStateConfigureModel - private func failuteState( - errorTitle: LocalizedStringKey, - errorSubTitle: LocalizedStringKey?, - errorIcon: String?, - errorSystemIcon: String?, - errorButtonTitle: LocalizedStringKey - ) -> Content { - AnyView( + public var body: some View { + VStack { + Spacer() + VStack { - Spacer() - if let errorSystemIcon { - Image(systemName: errorSystemIcon) - .font(.system(size: 60)) - .foregroundColor(.red) - } - - if let errorIcon { - Image(errorIcon) - } + Group { + if let icon = model.icon { + Image(icon) + } else if let systemName = model.systemName { + Image(systemName: systemName) + } + }.imageScale(.large) + .font(.largeTitle) - Text(errorTitle) + Text(model.title) .font(.title) .fontWeight(.bold) .multilineTextAlignment(.center) - .padding(.top) - if let errorSubTitle { - Text(errorSubTitle) - .font(.headline) - .fontWeight(.bold) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) + }.padding(.bottom) + + + if let subtitle = model.subtitle { + Group { + if #available(iOS 15.0, *) { + Text(subtitle) + .foregroundStyle(.secondary) + } else { + Text(subtitle) + .foregroundColor(.secondary) + } + }.font(.headline) + .multilineTextAlignment(.center) + } + + Spacer() + + if let buttonTitle = model.buttonTitle { + if #available(iOS 15.0, *) { + Button { + model.buttonAction?() + } label: { + Spacer() + Text(buttonTitle) + .bold() + .padding(.vertical, 5) + Spacer() + }.buttonStyle(.borderedProminent) + .padding(.horizontal) + } else { + Button { + model.buttonAction?() + } label: { + Spacer() + Text(buttonTitle) + .bold() + Spacer() + }.modifier(ErrorStateButtonModifier()) + .padding(.horizontal) } - - Spacer() - Button { - viewModel.refresh() - } label: { - Text(errorButtonTitle) - .padding() - .background(Color.red.opacity(0.3)) - }.accentColor(Color.red) - .clipShape(Capsule()) - Spacer() } - ) + } } } -@available(macOS 11.0, *) -@available(iOS 15.0, *) -private struct Example_Preview: PreviewProvider { - static var previews: some View { - Example() +@frozen public struct LoadingStateView: View { + public var body: some View { + VStack { + ProgressView() + .scaleEffect(1.5) + Text("Loading...") + .font(.caption) + .padding(.top) + } + } +} + +@frozen public struct ErrorStateButtonModifier: ViewModifier { + public func body(content: Content) -> some View { + content + .padding(.vertical, 5) + .foregroundColor(.primary) + .background(Color.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +@frozen public enum PageStates { + case loading + case successful + case failure +} + +private final class ExampleViewModel: ErrorableBaseViewModel { + override init() { + super.init() + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.pageState = .failure + } + } + + func refreshPage() { + self.pageState = .loading + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.pageState = .successful + } } } -@available(macOS 11.0, *) @available(iOS 15.0, *) -private struct Example: ErrorableViewProtocol { - typealias Content = AnyView +private struct ExampleView: ErrorableSheetView { typealias ViewModel = ExampleViewModel @ObservedObject var viewModel: ExampleViewModel = ExampleViewModel() - var body: some View { + var content: some View { NavigationView { - createErrorableView(errorTitle: "Upps!", errorSubTitle: "We encountered an error.\n Please try again later!", errorSystemIcon: "minus.diamond.fill", errorButtonTitle: "Try Again") { - AnyView ( - ScrollView { - ForEach(0..<100, id: \.self) { _ in - AsyncImage(url: URL(string: "https://picsum.photos/200")) { phase in - if let image = phase.image { - image - .resizable() - .scaledToFill() - } else { - Color.gray - } - }.frame(width: 300, height: 200, alignment: .center) + ScrollView { + ForEach(0..<100, id: \.self) { _ in + AsyncImage(url: URL(string: "https://picsum.photos/1000")) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFill() + } else { + Color.gray } - } - ) - } - .navigationTitle("Example") - }.onAppear { - Task { @MainActor in - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - viewModel.state = .failure + }.frame(height: 200, alignment: .center) + .clipped() } - } + }.navigationTitle("Example") } } + + var errorStateConfigModel: ErrorStateConfigureModel { + ErrorStateConfigureModel.Builder() + .title("Error!") + .subtitle("We encountered an error.\n Please try again later!") + .systemName("exclamationmark.triangle") + .buttonTitle("Try Again!") + .buttonAction { + viewModel.refreshPage() + }.dismissAction{ + viewModel.refreshPage() + }.build() + } } -@available(macOS 11.0, *) @available(iOS 15.0, *) -private final class ExampleViewModel: ErrorableViewModelProtocol { - @Published var state: PageStates = .loading - func refresh() { - state = .successful +private struct SimpleExampleView: ErrorableView { + typealias ViewModel = ExampleViewModel + @ObservedObject var viewModel: ExampleViewModel = ExampleViewModel() + + var content: some View { + VStack { + Text("Loaded Statement!") + } } } + +@available(iOS 15.0, *) +#Preview { + ExampleView() +} diff --git a/Sources/ErrorableView/PageStates.swift b/Sources/ErrorableView/PageStates.swift deleted file mode 100644 index b434172..0000000 --- a/Sources/ErrorableView/PageStates.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// PageStates.swift -// -// -// Created by Mehmet Ateş on 26.08.2023. -// - -public enum PageStates { - case loading - case successful - case failure -}