Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
aeafed8
add basic implementation of SUModal
mikhailChelbaev Nov 27, 2024
84fa2ba
add transition animations for swiftui center modal
mikhailChelbaev Nov 27, 2024
9d245ba
Merge branch 'modal-uikit' into modal-swiftui
mikhailChelbaev Nov 28, 2024
aefbd26
extract subviews and helpers to separate files
mikhailChelbaev Nov 28, 2024
0ba9bc5
add presentation helpers for SUCenterModal
mikhailChelbaev Nov 28, 2024
cf7a8f0
add bottom modal file
mikhailChelbaev Nov 28, 2024
dd61e55
Merge branch 'modal-uikit' into modal-swiftui
mikhailChelbaev Nov 28, 2024
6531bb1
implement SUBottomModal animation
mikhailChelbaev Nov 28, 2024
62a916f
add modal presentation modifiers to have animation when modals are cl…
mikhailChelbaev Nov 28, 2024
73973dc
Merge branch 'modal-uikit' into modal-swiftui
mikhailChelbaev Nov 29, 2024
c0c45c4
add sumodal previews
mikhailChelbaev Nov 29, 2024
651ccfc
Merge branch 'modal-uikit' into modal-swiftui
mikhailChelbaev Nov 29, 2024
18fa199
remove previews from modals
mikhailChelbaev Nov 29, 2024
2ae40fb
add docs for modal vms
mikhailChelbaev Nov 29, 2024
8e5f60e
replace `opaque` overlay with `transparent`
mikhailChelbaev Nov 29, 2024
894f999
add docs for other models related to modal
mikhailChelbaev Nov 29, 2024
ce07794
Merge branch 'modal-uikit' into modal-swiftui
mikhailChelbaev Nov 29, 2024
82e3e23
add docs for center and bottom modal modifiers
mikhailChelbaev Nov 29, 2024
0a66a09
fix: align modal content to the leading corner
mikhailChelbaev Nov 29, 2024
29af48e
merge with modal-uikit
mikhailChelbaev Dec 20, 2024
c36cc69
Merge branch 'dev' into modal-swiftui
mikhailChelbaev Dec 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bool>,
isCheckboxSelected: Binding<Bool>,
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
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import SwiftUI

struct ModalPresentationModifier<Modal: View>: ViewModifier {
@State var isPresented: Bool = false
@Binding var isContentVisible: Bool

@ViewBuilder var content: () -> Modal

let transitionDuration: TimeInterval
let onDismiss: (() -> Void)?

init(
isVisible: Binding<Bool>,
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<Modal: View>(
isVisible: Binding<Bool>,
transitionDuration: TimeInterval,
onDismiss: (() -> Void)? = nil,
@ViewBuilder content: @escaping () -> Modal
) -> some View {
modifier(ModalPresentationModifier(
isVisible: isVisible,
transitionDuration: transitionDuration,
onDismiss: onDismiss,
content: content
))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import SwiftUI

struct ModalPresentationWithItemModifier<Modal: View, Item: Identifiable>: ViewModifier {
@State var presentedItem: Item?
@Binding var visibleItem: Item?

@ViewBuilder var content: (Item) -> Modal

let transitionDuration: TimeInterval
let onDismiss: (() -> Void)?

init(
item: Binding<Item?>,
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<Modal: View, Item: Identifiable>(
item: Binding<Item?>,
transitionDuration: TimeInterval,
onDismiss: (() -> Void)? = nil,
@ViewBuilder content: @escaping (Item) -> Modal
) -> some View {
modifier(ModalPresentationWithItemModifier(
item: item,
transitionDuration: transitionDuration,
onDismiss: onDismiss,
content: content
))
}
}
57 changes: 57 additions & 0 deletions Sources/ComponentsKit/Modal/SwiftUI/Helpers/View+Helpers.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
)
}
}
81 changes: 81 additions & 0 deletions Sources/ComponentsKit/Modal/SwiftUI/ModalContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import SwiftUI

struct ModalContent<VM: ModalVM, Header: View, Body: View, Footer: View>: 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
}
}
Loading
Loading