diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift index 1e10b54b..f92c961c 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift @@ -197,4 +197,60 @@ Enim habitant laoreet inceptos scelerisque senectus, tellus molestie ut. Eros ri } } } + + // MARK: - SwiftUI + + static func suHeader(hasHeader: Bool) -> some View { + Group { + if hasHeader { + HStack { + Text(self.headerTitle) + .font(self.headerFont.font) + } + } else { + EmptyView() + } + } + } + + static func suBody(body: ContentBody) -> some View { + Group { + switch body { + case .shortText: + Text(self.bodyShortText) + case .longText: + Text(self.bodyLongText) + } + } + .font(self.bodyFont.font) + } + + static func suFooter( + isPresented: Binding, + isCheckboxSelected: Binding, + footer: ContentFooter? + ) -> some View { + Group { + switch footer { + case .none: + EmptyView() + case .button: + SUButton(model: self.footerButtonVM) { + isPresented.wrappedValue = false + } + case .buttonAndCheckbox: + VStack(alignment: .leading, spacing: 16) { + SUCheckbox( + isSelected: isCheckboxSelected, + model: self.footerCheckboxVM + ) + SUButton(model: self.footerButtonVM.updating { + $0.isEnabled = isCheckboxSelected.wrappedValue + }) { + isPresented.wrappedValue = false + } + } + } + } + } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift index 83a6ce3b..88931092 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift @@ -28,7 +28,28 @@ struct BottomModalPreview: View { } .preview } - + PreviewWrapper(title: "SwiftUI") { + SUButton(model: .init { $0.title = "Show Modal" }) { + self.isModalPresented = true + } + .bottomModal( + isPresented: self.$isModalPresented, + model: self.model, + header: { + ModalPreviewHelpers.suHeader(hasHeader: self.hasHeader) + }, + body: { + ModalPreviewHelpers.suBody(body: self.contentBody) + }, + footer: { + ModalPreviewHelpers.suFooter( + isPresented: self.$isModalPresented, + isCheckboxSelected: self.$isCheckboxSelected, + footer: self.contentFooter + ) + } + ) + } Form { ModalPreviewHelpers.ContentSection( model: self.$model, diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CenterModalPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CenterModalPreview.swift index 4c968ce7..24db6a02 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CenterModalPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CenterModalPreview.swift @@ -28,6 +28,28 @@ struct CenterModalPreview: View { } .preview } + PreviewWrapper(title: "SwiftUI") { + SUButton(model: .init { $0.title = "Show Modal" }) { + self.isModalPresented = true + } + .centerModal( + isPresented: self.$isModalPresented, + model: self.model, + header: { + ModalPreviewHelpers.suHeader(hasHeader: self.hasHeader) + }, + body: { + ModalPreviewHelpers.suBody(body: self.contentBody) + }, + footer: { + ModalPreviewHelpers.suFooter( + isPresented: self.$isModalPresented, + isCheckboxSelected: self.$isCheckboxSelected, + footer: self.contentFooter + ) + } + ) + } Form { ModalPreviewHelpers.ContentSection( model: self.$model, diff --git a/Sources/ComponentsKit/Modal/SwiftUI/Helpers/ModalPresentationModifier.swift b/Sources/ComponentsKit/Modal/SwiftUI/Helpers/ModalPresentationModifier.swift new file mode 100644 index 00000000..3eb02ad4 --- /dev/null +++ b/Sources/ComponentsKit/Modal/SwiftUI/Helpers/ModalPresentationModifier.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct ModalPresentationModifier: ViewModifier { + @State var isPresented: Bool = false + @Binding var isContentVisible: Bool + + @ViewBuilder var content: () -> Modal + + let transitionDuration: TimeInterval + let onDismiss: (() -> Void)? + + init( + isVisible: Binding, + transitionDuration: TimeInterval, + onDismiss: (() -> Void)?, + @ViewBuilder content: @escaping () -> Modal + ) { + self._isContentVisible = isVisible + self.transitionDuration = transitionDuration + self.onDismiss = onDismiss + self.content = content + } + + func body(content: Content) -> some View { + content + .onChange(of: self.isContentVisible) { newValue in + if newValue { + self.isPresented = true + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + self.transitionDuration) { + self.isPresented = false + } + } + } + .fullScreenCover( + isPresented: self.$isPresented, + onDismiss: self.onDismiss, + content: { + self.content() + .transparentPresentationBackground() + } + ) + .transaction { + $0.disablesAnimations = true + } + } +} + +extension View { + func modal( + isVisible: Binding, + transitionDuration: TimeInterval, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> Modal + ) -> some View { + modifier(ModalPresentationModifier( + isVisible: isVisible, + transitionDuration: transitionDuration, + onDismiss: onDismiss, + content: content + )) + } +} diff --git a/Sources/ComponentsKit/Modal/SwiftUI/Helpers/ModalPresentationWithItemModifier.swift b/Sources/ComponentsKit/Modal/SwiftUI/Helpers/ModalPresentationWithItemModifier.swift new file mode 100644 index 00000000..31e7cdc8 --- /dev/null +++ b/Sources/ComponentsKit/Modal/SwiftUI/Helpers/ModalPresentationWithItemModifier.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct ModalPresentationWithItemModifier: ViewModifier { + @State var presentedItem: Item? + @Binding var visibleItem: Item? + + @ViewBuilder var content: (Item) -> Modal + + let transitionDuration: TimeInterval + let onDismiss: (() -> Void)? + + init( + item: Binding, + transitionDuration: TimeInterval, + onDismiss: (() -> Void)?, + @ViewBuilder content: @escaping (Item) -> Modal + ) { + self._visibleItem = item + self.transitionDuration = transitionDuration + self.onDismiss = onDismiss + self.content = content + } + + func body(content: Content) -> some View { + content + .onChange(of: self.visibleItem.isNotNil) { newValue in + if newValue { + self.presentedItem = self.visibleItem + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + self.transitionDuration) { + self.presentedItem = self.visibleItem + } + } + } + .fullScreenCover( + item: self.$presentedItem, + onDismiss: self.onDismiss, + content: { item in + self.content(item) + .transparentPresentationBackground() + } + ) + .transaction { + $0.disablesAnimations = true + } + } +} + +extension View { + func modal( + item: Binding, + transitionDuration: TimeInterval, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Item) -> Modal + ) -> some View { + modifier(ModalPresentationWithItemModifier( + item: item, + transitionDuration: transitionDuration, + onDismiss: onDismiss, + content: content + )) + } +} diff --git a/Sources/ComponentsKit/Modal/SwiftUI/Helpers/View+Helpers.swift b/Sources/ComponentsKit/Modal/SwiftUI/Helpers/View+Helpers.swift new file mode 100644 index 00000000..0bfef577 --- /dev/null +++ b/Sources/ComponentsKit/Modal/SwiftUI/Helpers/View+Helpers.swift @@ -0,0 +1,57 @@ +import SwiftUI + +// MARK: - Transparent Presentation Background + +fileprivate struct TransparentBackground: UIViewRepresentable { + func makeUIView(context: Context) -> UIView { + let view = UIView() + DispatchQueue.main.async { + view.superview?.superview?.backgroundColor = .clear + } + return view + } + func updateUIView(_ uiView: UIView, context: Context) {} +} + +extension View { + func transparentPresentationBackground() -> some View { + if #available(iOS 16.4, *) { + return self.presentationBackground(Color.clear) + } else { + return self.background(TransparentBackground()) + } + } +} + +// MARK: - Disable Scroll When Content Fits + +extension View { + func disableScrollWhenContentFits() -> some View { + if #available(iOS 16.4, *) { + return self.scrollBounceBehavior(.basedOnSize, axes: [.vertical]) + } else { + return self.onAppear { + UIScrollView.appearance().bounces = false + } + } + } +} + +// MARK: - Observe Size + +// TODO: Move to Shared Helpers +extension View { + func observeSize(_ closure: @escaping (_ size: CGSize) -> Void) -> some View { + return self.overlay( + GeometryReader { geometry in + Color.clear + .onAppear { + closure(geometry.size) + } + .onChange(of: geometry.size) { newValue in + closure(newValue) + } + } + ) + } +} diff --git a/Sources/ComponentsKit/Modal/SwiftUI/ModalContent.swift b/Sources/ComponentsKit/Modal/SwiftUI/ModalContent.swift new file mode 100644 index 00000000..8e95c809 --- /dev/null +++ b/Sources/ComponentsKit/Modal/SwiftUI/ModalContent.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct ModalContent: View { + let model: VM + + @ViewBuilder let contentHeader: () -> Header + @ViewBuilder let contentBody: () -> Body + @ViewBuilder let contentFooter: () -> Footer + + @State private var headerSize: CGSize = .zero + @State private var bodySize: CGSize = .zero + @State private var footerSize: CGSize = .zero + + init( + model: VM, + @ViewBuilder header: @escaping () -> Header, + @ViewBuilder body: @escaping () -> Body, + @ViewBuilder footer: @escaping () -> Footer + ) { + self.model = model + self.contentHeader = header + self.contentBody = body + self.contentFooter = footer + } + + var body: some View { + VStack( + alignment: .leading, + spacing: self.model.contentSpacing + ) { + self.contentHeader() + .observeSize { + self.headerSize = $0 + } + .padding(.top, self.model.contentPaddings.top) + .padding(.leading, self.model.contentPaddings.leading) + .padding(.trailing, self.model.contentPaddings.trailing) + + ScrollView { + self.contentBody() + .padding(.leading, self.model.contentPaddings.leading) + .padding(.trailing, self.model.contentPaddings.trailing) + .observeSize { + self.bodySize = $0 + } + .padding(.top, self.bodyTopPadding) + .padding(.bottom, self.bodyBottomPadding) + } + .frame(maxHeight: self.scrollViewMaxHeight) + .disableScrollWhenContentFits() + + self.contentFooter() + .observeSize { + self.footerSize = $0 + } + .padding(.leading, self.model.contentPaddings.leading) + .padding(.trailing, self.model.contentPaddings.trailing) + .padding(.bottom, self.model.contentPaddings.bottom) + } + .frame(maxWidth: self.model.size.maxWidth, alignment: .leading) + .background(self.model.preferredBackgroundColor.color) + .background(UniversalColor.background.color) + .clipShape(RoundedRectangle( + cornerRadius: self.model.cornerRadius.value + )) + .padding(.top, self.model.outerPaddings.top) + .padding(.leading, self.model.outerPaddings.leading) + .padding(.bottom, self.model.outerPaddings.bottom) + .padding(.trailing, self.model.outerPaddings.trailing) + } + + private var bodyTopPadding: CGFloat { + return self.headerSize.height > 0 ? 0 : self.model.contentPaddings.top + } + private var bodyBottomPadding: CGFloat { + return self.footerSize.height > 0 ? 0 : self.model.contentPaddings.bottom + } + private var scrollViewMaxHeight: CGFloat { + return self.bodySize.height + self.bodyTopPadding + self.bodyBottomPadding + } +} diff --git a/Sources/ComponentsKit/Modal/SwiftUI/ModalOverlay.swift b/Sources/ComponentsKit/Modal/SwiftUI/ModalOverlay.swift new file mode 100644 index 00000000..42b8f40e --- /dev/null +++ b/Sources/ComponentsKit/Modal/SwiftUI/ModalOverlay.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct ModalOverlay: View { + let model: VM + + @Binding var isVisible: Bool + + init( + isVisible: Binding, + model: VM + ) { + self._isVisible = isVisible + self.model = model + } + + var body: some View { + Group { + switch self.model.overlayStyle { + case .dimmed: + Color.black.opacity(0.7) + case .blurred: + Color.clear.background(.ultraThinMaterial) + case .transparent: + // Note: It can't be completely transparent as it won't receive touch gestures. + Color.black.opacity(0.0001) + } + } + .ignoresSafeArea(.all) + .onTapGesture { + if self.model.closesOnOverlayTap { + self.isVisible = false + } + } + } +} diff --git a/Sources/ComponentsKit/Modal/SwiftUI/SUBottomModal.swift b/Sources/ComponentsKit/Modal/SwiftUI/SUBottomModal.swift new file mode 100644 index 00000000..000a1466 --- /dev/null +++ b/Sources/ComponentsKit/Modal/SwiftUI/SUBottomModal.swift @@ -0,0 +1,317 @@ +import SwiftUI + +struct SUBottomModal: View { + let model: BottomModalVM + + @Binding var isVisible: Bool + + @ViewBuilder let contentHeader: () -> Header + @ViewBuilder let contentBody: () -> Body + @ViewBuilder let contentFooter: () -> Footer + + @State private var contentHeight: CGFloat = 0 + @State private var contentOffsetY: CGFloat = 0 + @State private var overlayOpacity: CGFloat = 0 + + init( + isVisible: Binding, + model: BottomModalVM, + @ViewBuilder header: @escaping () -> Header, + @ViewBuilder body: @escaping () -> Body, + @ViewBuilder footer: @escaping () -> Footer + ) { + self._isVisible = isVisible + self.model = model + self.contentHeader = header + self.contentBody = body + self.contentFooter = footer + } + + var body: some View { + ZStack(alignment: .bottom) { + ModalOverlay(isVisible: self.$isVisible, model: self.model) + .opacity(self.overlayOpacity) + + ModalContent(model: self.model, header: self.contentHeader, body: self.contentBody, footer: self.contentFooter) + .observeSize { + self.contentHeight = $0.height + } + .offset(y: self.contentOffsetY) + .gesture( + DragGesture() + .onChanged { gesture in + let translation = gesture.translation.height + self.contentOffsetY = ModalAnimation.bottomModalOffset(translation, model: self.model) + } + .onEnded { gesture in + if ModalAnimation.shouldHideBottomModal( + offset: self.contentOffsetY, + height: self.contentHeight, + velocity: gesture.velocity.height, + model: self.model + ) { + self.isVisible = false + } else { + withAnimation(.linear(duration: 0.2)) { + self.contentOffsetY = 0 + } + } + } + ) + } + .onAppear { + self.contentOffsetY = self.screenHeight + + withAnimation(.linear(duration: self.model.transition.value)) { + self.overlayOpacity = 1.0 + self.contentOffsetY = 0 + } + } + .onChange(of: self.isVisible) { newValue in + withAnimation(.linear(duration: self.model.transition.value)) { + if newValue { + self.overlayOpacity = 1.0 + self.contentOffsetY = 0 + } else { + self.overlayOpacity = 0.0 + self.contentOffsetY = self.screenHeight + } + } + } + } + + // MARK: - Helpers + + private var screenHeight: CGFloat { + return UIScreen.main.bounds.height + } +} + +// MARK: - Presentation Helpers + +extension View { + /// A SwiftUI view modifier that presents a bottom-aligned modal. + /// + /// This modifier allows you to attach a bottom modal to any SwiftUI view, providing a structured way to display modals + /// with a header, body, and footer, all styled and laid out according to the provided `BottomModalVM` model. + /// + /// - Parameters: + /// - isPresented: A binding that determines whether the modal is presented. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. + /// - header: A closure that provides the view for the modal's header. + /// - body: A closure that provides the view for the modal's main content. + /// - footer: A closure that provides the view for the modal's footer. + /// + /// - Returns: A modified `View` with a bottom modal attached. + /// + /// - Example: + /// ```swift + /// SomeView() + /// .bottomModal( + /// isPresented: $isModalPresented, + /// model: BottomModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// header: { + /// Text("Header") + /// }, + /// body: { + /// Text("Body content goes here") + /// }, + /// footer: { + /// SUButton(model: .init { + /// $0.title = "Close" + /// }) { + /// isModalPresented = false + /// } + /// } + /// ) + /// ``` + public func bottomModal( + isPresented: Binding, + model: BottomModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder header: @escaping () -> Header = { EmptyView() }, + @ViewBuilder body: @escaping () -> Body, + @ViewBuilder footer: @escaping () -> Footer = { EmptyView() } + ) -> some View { + return self.modal( + isVisible: isPresented, + transitionDuration: model.transition.value, + onDismiss: onDismiss, + content: { + SUBottomModal( + isVisible: isPresented, + model: model, + header: header, + body: body, + footer: footer + ) + } + ) + } +} + +extension View { + /// A SwiftUI view modifier that presents a bottom-aligned modal bound to an optional identifiable item. + /// + /// This modifier allows you to attach a modal to any SwiftUI view, which is displayed when the `item` binding + /// is non-`nil`. The modal content is dynamically generated based on the unwrapped `Item`. + /// + /// - Parameters: + /// - item: A binding to an optional `Item` that determines whether the modal is presented. + /// When `item` is `nil`, the modal is hidden. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. Defaults to `nil`. + /// - header: A closure that provides the view for the modal's header, based on the unwrapped `Item`. + /// - body: A closure that provides the view for the modal's main content, based on the unwrapped `Item`. + /// - footer: A closure that provides the view for the modal's footer, based on the unwrapped `Item`. + /// + /// - Returns: A modified `View` with a bottom modal attached. + /// + /// - Example: + /// ```swift + /// struct ContentView: View { + /// struct ModalData: Identifiable { + /// var id: String { + /// return text + /// } + /// let text: String + /// } + /// + /// @State private var selectedItem: ModalData? + /// private let items: [ModalData] = [ + /// ModalData(text: "data 1"), + /// ModalData(text: "data 2") + /// ] + /// + /// var body: some View { + /// List(items) { item in + /// Button("Show Modal") { + /// selectedItem = item + /// } + /// } + /// .bottomModal( + /// item: $selectedItem, + /// model: BottomModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// header: { item in + /// Text("Header for \(item.text)") + /// }, + /// body: { item in + /// Text("Body content for \(item.text)") + /// }, + /// footer: { _ in + /// SUButton(model: .init { + /// $0.title = "Close" + /// }) { + /// selectedItem = nil + /// } + /// } + /// ) + /// } + /// } + /// ``` + public func bottomModal( + item: Binding, + model: BottomModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder header: @escaping (Item) -> Header, + @ViewBuilder body: @escaping (Item) -> Body, + @ViewBuilder footer: @escaping (Item) -> Footer + ) -> some View { + return self.modal( + item: item, + transitionDuration: model.transition.value, + onDismiss: onDismiss, + content: { unwrappedItem in + SUBottomModal( + isVisible: .init( + get: { + return item.wrappedValue.isNotNil + }, + set: { isPresented in + if isPresented { + item.wrappedValue = unwrappedItem + } else { + item.wrappedValue = nil + } + } + ), + model: model, + header: { header(unwrappedItem) }, + body: { body(unwrappedItem) }, + footer: { footer(unwrappedItem) } + ) + } + ) + } + + /// A SwiftUI view modifier that presents a bottom-aligned modal bound to an optional identifiable item. + /// + /// This modifier allows you to attach a modal to any SwiftUI view, which is displayed when the `item` binding + /// is non-`nil`. The modal content is dynamically generated based on the unwrapped `Item`. + /// + /// - Parameters: + /// - item: A binding to an optional `Item` that determines whether the modal is presented. + /// When `item` is `nil`, the modal is hidden. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. Defaults to `nil`. + /// - body: A closure that provides the view for the modal's main content, based on the unwrapped `Item`. + /// + /// - Returns: A modified `View` with a bottom modal attached. + /// + /// - Example: + /// ```swift + /// struct ContentView: View { + /// struct ModalData: Identifiable { + /// var id: String { + /// return text + /// } + /// let text: String + /// } + /// + /// @State private var selectedItem: ModalData? + /// private let items: [ModalData] = [ + /// ModalData(text: "data 1"), + /// ModalData(text: "data 2") + /// ] + /// + /// var body: some View { + /// List(items) { item in + /// Button("Show Modal") { + /// selectedItem = item + /// } + /// } + /// .bottomModal( + /// item: $selectedItem, + /// model: BottomModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// body: { item in + /// Text("Body content for \(item.text)") + /// } + /// ) + /// } + /// } + /// ``` + public func bottomModal( + item: Binding, + model: BottomModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder body: @escaping (Item) -> Body + ) -> some View { + return self.bottomModal( + item: item, + model: model, + header: { _ in EmptyView() }, + body: body, + footer: { _ in EmptyView() } + ) + } +} diff --git a/Sources/ComponentsKit/Modal/SwiftUI/SUCenterModal.swift b/Sources/ComponentsKit/Modal/SwiftUI/SUCenterModal.swift new file mode 100644 index 00000000..6d5280a5 --- /dev/null +++ b/Sources/ComponentsKit/Modal/SwiftUI/SUCenterModal.swift @@ -0,0 +1,279 @@ +import SwiftUI + +struct SUCenterModal: View { + let model: CenterModalVM + + @Binding var isVisible: Bool + + @ViewBuilder let contentHeader: () -> Header + @ViewBuilder let contentBody: () -> Body + @ViewBuilder let contentFooter: () -> Footer + + @State private var contentOpacity: CGFloat = 0 + + init( + isVisible: Binding, + model: CenterModalVM, + @ViewBuilder header: @escaping () -> Header, + @ViewBuilder body: @escaping () -> Body, + @ViewBuilder footer: @escaping () -> Footer + ) { + self._isVisible = isVisible + self.model = model + self.contentHeader = header + self.contentBody = body + self.contentFooter = footer + } + + var body: some View { + ZStack(alignment: .center) { + ModalOverlay(isVisible: self.$isVisible, model: self.model) + + ModalContent(model: self.model, header: self.contentHeader, body: self.contentBody, footer: self.contentFooter) + } + .opacity(self.contentOpacity) + .onAppear { + withAnimation(.linear(duration: self.model.transition.value)) { + self.contentOpacity = 1.0 + } + } + .onChange(of: self.isVisible) { newValue in + withAnimation(.linear(duration: self.model.transition.value)) { + if newValue { + self.contentOpacity = 1.0 + } else { + self.contentOpacity = 0.0 + } + } + } + } +} + +// MARK: - Presentation Helpers + +extension View { + /// A SwiftUI view modifier that presents a center-aligned modal. + /// + /// This modifier allows you to attach a cetner modal to any SwiftUI view, providing a structured way to display modals + /// with a header, body, and footer, all styled and laid out according to the provided `CenterModalVM` model. + /// + /// - Parameters: + /// - isPresented: A binding that determines whether the modal is presented. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. + /// - header: A closure that provides the view for the modal's header. + /// - body: A closure that provides the view for the modal's main content. + /// - footer: A closure that provides the view for the modal's footer. + /// + /// - Returns: A modified `View` with a center modal attached. + /// + /// - Example: + /// ```swift + /// SomeView() + /// .centerModal( + /// isPresented: $isModalPresented, + /// model: CenterModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// header: { + /// Text("Header") + /// }, + /// body: { + /// Text("Body content goes here") + /// }, + /// footer: { + /// SUButton(model: .init { + /// $0.title = "Close" + /// }) { + /// isModalPresented = false + /// } + /// } + /// ) + /// ``` + public func centerModal( + isPresented: Binding, + model: CenterModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder header: @escaping () -> Header = { EmptyView() }, + @ViewBuilder body: @escaping () -> Body, + @ViewBuilder footer: @escaping () -> Footer = { EmptyView() } + ) -> some View { + return self.modal( + isVisible: isPresented, + transitionDuration: model.transition.value, + onDismiss: onDismiss, + content: { + SUCenterModal( + isVisible: isPresented, + model: model, + header: header, + body: body, + footer: footer + ) + } + ) + } +} + +extension View { + /// A SwiftUI view modifier that presents a center-aligned modal bound to an optional identifiable item. + /// + /// This modifier allows you to attach a modal to any SwiftUI view, which is displayed when the `item` binding + /// is non-`nil`. The modal content is dynamically generated based on the unwrapped `Item`. + /// + /// - Parameters: + /// - item: A binding to an optional `Item` that determines whether the modal is presented. + /// When `item` is `nil`, the modal is hidden. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. Defaults to `nil`. + /// - header: A closure that provides the view for the modal's header, based on the unwrapped `Item`. + /// - body: A closure that provides the view for the modal's main content, based on the unwrapped `Item`. + /// - footer: A closure that provides the view for the modal's footer, based on the unwrapped `Item`. + /// + /// - Returns: A modified `View` with a center modal attached. + /// + /// - Example: + /// ```swift + /// struct ContentView: View { + /// struct ModalData: Identifiable { + /// var id: String { + /// return text + /// } + /// let text: String + /// } + /// + /// @State private var selectedItem: ModalData? + /// private let items: [ModalData] = [ + /// ModalData(text: "data 1"), + /// ModalData(text: "data 2") + /// ] + /// + /// var body: some View { + /// List(items) { item in + /// Button("Show Modal") { + /// selectedItem = item + /// } + /// } + /// .centerModal( + /// item: $selectedItem, + /// model: CenterModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// header: { item in + /// Text("Header for \(item.text)") + /// }, + /// body: { item in + /// Text("Body content for \(item.text)") + /// }, + /// footer: { _ in + /// SUButton(model: .init { + /// $0.title = "Close" + /// }) { + /// selectedItem = nil + /// } + /// } + /// ) + /// } + /// } + /// ``` + public func centerModal( + item: Binding, + model: CenterModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder header: @escaping (Item) -> Header, + @ViewBuilder body: @escaping (Item) -> Body, + @ViewBuilder footer: @escaping (Item) -> Footer + ) -> some View { + return self.modal( + item: item, + transitionDuration: model.transition.value, + onDismiss: onDismiss, + content: { unwrappedItem in + SUCenterModal( + isVisible: .init( + get: { + return item.wrappedValue.isNotNil + }, + set: { isPresented in + if isPresented { + item.wrappedValue = unwrappedItem + } else { + item.wrappedValue = nil + } + } + ), + model: model, + header: { header(unwrappedItem) }, + body: { body(unwrappedItem) }, + footer: { footer(unwrappedItem) } + ) + } + ) + } + + /// A SwiftUI view modifier that presents a center-aligned modal bound to an optional identifiable item. + /// + /// This modifier allows you to attach a modal to any SwiftUI view, which is displayed when the `item` binding + /// is non-`nil`. The modal content is dynamically generated based on the unwrapped `Item`. + /// + /// - Parameters: + /// - item: A binding to an optional `Item` that determines whether the modal is presented. + /// When `item` is `nil`, the modal is hidden. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. Defaults to `nil`. + /// - body: A closure that provides the view for the modal's main content, based on the unwrapped `Item`. + /// + /// - Returns: A modified `View` with a center modal attached. + /// + /// - Example: + /// ```swift + /// struct ContentView: View { + /// struct ModalData: Identifiable { + /// var id: String { + /// return text + /// } + /// let text: String + /// } + /// + /// @State private var selectedItem: ModalData? + /// private let items: [ModalData] = [ + /// ModalData(text: "data 1"), + /// ModalData(text: "data 2") + /// ] + /// + /// var body: some View { + /// List(items) { item in + /// Button("Show Modal") { + /// selectedItem = item + /// } + /// } + /// .centerModal( + /// item: $selectedItem, + /// model: CenterModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// body: { item in + /// Text("Body content for \(item.text)") + /// } + /// ) + /// } + /// } + /// ``` + public func centerModal( + item: Binding, + model: CenterModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder body: @escaping (Item) -> Body + ) -> some View { + return self.centerModal( + item: item, + model: model, + header: { _ in EmptyView() }, + body: body, + footer: { _ in EmptyView() } + ) + } +}