Skip to content

Commit d0d059e

Browse files
Merge pull request #29 from componentskit/modal-swiftui
SUBottomModal and SUCenterModal
2 parents ba11ff7 + c36cc69 commit d0d059e

File tree

10 files changed

+995
-1
lines changed

10 files changed

+995
-1
lines changed

Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,4 +197,60 @@ Enim habitant laoreet inceptos scelerisque senectus, tellus molestie ut. Eros ri
197197
}
198198
}
199199
}
200+
201+
// MARK: - SwiftUI
202+
203+
static func suHeader(hasHeader: Bool) -> some View {
204+
Group {
205+
if hasHeader {
206+
HStack {
207+
Text(self.headerTitle)
208+
.font(self.headerFont.font)
209+
}
210+
} else {
211+
EmptyView()
212+
}
213+
}
214+
}
215+
216+
static func suBody(body: ContentBody) -> some View {
217+
Group {
218+
switch body {
219+
case .shortText:
220+
Text(self.bodyShortText)
221+
case .longText:
222+
Text(self.bodyLongText)
223+
}
224+
}
225+
.font(self.bodyFont.font)
226+
}
227+
228+
static func suFooter(
229+
isPresented: Binding<Bool>,
230+
isCheckboxSelected: Binding<Bool>,
231+
footer: ContentFooter?
232+
) -> some View {
233+
Group {
234+
switch footer {
235+
case .none:
236+
EmptyView()
237+
case .button:
238+
SUButton(model: self.footerButtonVM) {
239+
isPresented.wrappedValue = false
240+
}
241+
case .buttonAndCheckbox:
242+
VStack(alignment: .leading, spacing: 16) {
243+
SUCheckbox(
244+
isSelected: isCheckboxSelected,
245+
model: self.footerCheckboxVM
246+
)
247+
SUButton(model: self.footerButtonVM.updating {
248+
$0.isEnabled = isCheckboxSelected.wrappedValue
249+
}) {
250+
isPresented.wrappedValue = false
251+
}
252+
}
253+
}
254+
}
255+
}
200256
}

Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,28 @@ struct BottomModalPreview: View {
2828
}
2929
.preview
3030
}
31-
31+
PreviewWrapper(title: "SwiftUI") {
32+
SUButton(model: .init { $0.title = "Show Modal" }) {
33+
self.isModalPresented = true
34+
}
35+
.bottomModal(
36+
isPresented: self.$isModalPresented,
37+
model: self.model,
38+
header: {
39+
ModalPreviewHelpers.suHeader(hasHeader: self.hasHeader)
40+
},
41+
body: {
42+
ModalPreviewHelpers.suBody(body: self.contentBody)
43+
},
44+
footer: {
45+
ModalPreviewHelpers.suFooter(
46+
isPresented: self.$isModalPresented,
47+
isCheckboxSelected: self.$isCheckboxSelected,
48+
footer: self.contentFooter
49+
)
50+
}
51+
)
52+
}
3253
Form {
3354
ModalPreviewHelpers.ContentSection(
3455
model: self.$model,

Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CenterModalPreview.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,28 @@ struct CenterModalPreview: View {
2828
}
2929
.preview
3030
}
31+
PreviewWrapper(title: "SwiftUI") {
32+
SUButton(model: .init { $0.title = "Show Modal" }) {
33+
self.isModalPresented = true
34+
}
35+
.centerModal(
36+
isPresented: self.$isModalPresented,
37+
model: self.model,
38+
header: {
39+
ModalPreviewHelpers.suHeader(hasHeader: self.hasHeader)
40+
},
41+
body: {
42+
ModalPreviewHelpers.suBody(body: self.contentBody)
43+
},
44+
footer: {
45+
ModalPreviewHelpers.suFooter(
46+
isPresented: self.$isModalPresented,
47+
isCheckboxSelected: self.$isCheckboxSelected,
48+
footer: self.contentFooter
49+
)
50+
}
51+
)
52+
}
3153
Form {
3254
ModalPreviewHelpers.ContentSection(
3355
model: self.$model,
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import SwiftUI
2+
3+
struct ModalPresentationModifier<Modal: View>: ViewModifier {
4+
@State var isPresented: Bool = false
5+
@Binding var isContentVisible: Bool
6+
7+
@ViewBuilder var content: () -> Modal
8+
9+
let transitionDuration: TimeInterval
10+
let onDismiss: (() -> Void)?
11+
12+
init(
13+
isVisible: Binding<Bool>,
14+
transitionDuration: TimeInterval,
15+
onDismiss: (() -> Void)?,
16+
@ViewBuilder content: @escaping () -> Modal
17+
) {
18+
self._isContentVisible = isVisible
19+
self.transitionDuration = transitionDuration
20+
self.onDismiss = onDismiss
21+
self.content = content
22+
}
23+
24+
func body(content: Content) -> some View {
25+
content
26+
.onChange(of: self.isContentVisible) { newValue in
27+
if newValue {
28+
self.isPresented = true
29+
} else {
30+
DispatchQueue.main.asyncAfter(deadline: .now() + self.transitionDuration) {
31+
self.isPresented = false
32+
}
33+
}
34+
}
35+
.fullScreenCover(
36+
isPresented: self.$isPresented,
37+
onDismiss: self.onDismiss,
38+
content: {
39+
self.content()
40+
.transparentPresentationBackground()
41+
}
42+
)
43+
.transaction {
44+
$0.disablesAnimations = true
45+
}
46+
}
47+
}
48+
49+
extension View {
50+
func modal<Modal: View>(
51+
isVisible: Binding<Bool>,
52+
transitionDuration: TimeInterval,
53+
onDismiss: (() -> Void)? = nil,
54+
@ViewBuilder content: @escaping () -> Modal
55+
) -> some View {
56+
modifier(ModalPresentationModifier(
57+
isVisible: isVisible,
58+
transitionDuration: transitionDuration,
59+
onDismiss: onDismiss,
60+
content: content
61+
))
62+
}
63+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import SwiftUI
2+
3+
struct ModalPresentationWithItemModifier<Modal: View, Item: Identifiable>: ViewModifier {
4+
@State var presentedItem: Item?
5+
@Binding var visibleItem: Item?
6+
7+
@ViewBuilder var content: (Item) -> Modal
8+
9+
let transitionDuration: TimeInterval
10+
let onDismiss: (() -> Void)?
11+
12+
init(
13+
item: Binding<Item?>,
14+
transitionDuration: TimeInterval,
15+
onDismiss: (() -> Void)?,
16+
@ViewBuilder content: @escaping (Item) -> Modal
17+
) {
18+
self._visibleItem = item
19+
self.transitionDuration = transitionDuration
20+
self.onDismiss = onDismiss
21+
self.content = content
22+
}
23+
24+
func body(content: Content) -> some View {
25+
content
26+
.onChange(of: self.visibleItem.isNotNil) { newValue in
27+
if newValue {
28+
self.presentedItem = self.visibleItem
29+
} else {
30+
DispatchQueue.main.asyncAfter(deadline: .now() + self.transitionDuration) {
31+
self.presentedItem = self.visibleItem
32+
}
33+
}
34+
}
35+
.fullScreenCover(
36+
item: self.$presentedItem,
37+
onDismiss: self.onDismiss,
38+
content: { item in
39+
self.content(item)
40+
.transparentPresentationBackground()
41+
}
42+
)
43+
.transaction {
44+
$0.disablesAnimations = true
45+
}
46+
}
47+
}
48+
49+
extension View {
50+
func modal<Modal: View, Item: Identifiable>(
51+
item: Binding<Item?>,
52+
transitionDuration: TimeInterval,
53+
onDismiss: (() -> Void)? = nil,
54+
@ViewBuilder content: @escaping (Item) -> Modal
55+
) -> some View {
56+
modifier(ModalPresentationWithItemModifier(
57+
item: item,
58+
transitionDuration: transitionDuration,
59+
onDismiss: onDismiss,
60+
content: content
61+
))
62+
}
63+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import SwiftUI
2+
3+
// MARK: - Transparent Presentation Background
4+
5+
fileprivate struct TransparentBackground: UIViewRepresentable {
6+
func makeUIView(context: Context) -> UIView {
7+
let view = UIView()
8+
DispatchQueue.main.async {
9+
view.superview?.superview?.backgroundColor = .clear
10+
}
11+
return view
12+
}
13+
func updateUIView(_ uiView: UIView, context: Context) {}
14+
}
15+
16+
extension View {
17+
func transparentPresentationBackground() -> some View {
18+
if #available(iOS 16.4, *) {
19+
return self.presentationBackground(Color.clear)
20+
} else {
21+
return self.background(TransparentBackground())
22+
}
23+
}
24+
}
25+
26+
// MARK: - Disable Scroll When Content Fits
27+
28+
extension View {
29+
func disableScrollWhenContentFits() -> some View {
30+
if #available(iOS 16.4, *) {
31+
return self.scrollBounceBehavior(.basedOnSize, axes: [.vertical])
32+
} else {
33+
return self.onAppear {
34+
UIScrollView.appearance().bounces = false
35+
}
36+
}
37+
}
38+
}
39+
40+
// MARK: - Observe Size
41+
42+
// TODO: Move to Shared Helpers
43+
extension View {
44+
func observeSize(_ closure: @escaping (_ size: CGSize) -> Void) -> some View {
45+
return self.overlay(
46+
GeometryReader { geometry in
47+
Color.clear
48+
.onAppear {
49+
closure(geometry.size)
50+
}
51+
.onChange(of: geometry.size) { newValue in
52+
closure(newValue)
53+
}
54+
}
55+
)
56+
}
57+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import SwiftUI
2+
3+
struct ModalContent<VM: ModalVM, Header: View, Body: View, Footer: View>: View {
4+
let model: VM
5+
6+
@ViewBuilder let contentHeader: () -> Header
7+
@ViewBuilder let contentBody: () -> Body
8+
@ViewBuilder let contentFooter: () -> Footer
9+
10+
@State private var headerSize: CGSize = .zero
11+
@State private var bodySize: CGSize = .zero
12+
@State private var footerSize: CGSize = .zero
13+
14+
init(
15+
model: VM,
16+
@ViewBuilder header: @escaping () -> Header,
17+
@ViewBuilder body: @escaping () -> Body,
18+
@ViewBuilder footer: @escaping () -> Footer
19+
) {
20+
self.model = model
21+
self.contentHeader = header
22+
self.contentBody = body
23+
self.contentFooter = footer
24+
}
25+
26+
var body: some View {
27+
VStack(
28+
alignment: .leading,
29+
spacing: self.model.contentSpacing
30+
) {
31+
self.contentHeader()
32+
.observeSize {
33+
self.headerSize = $0
34+
}
35+
.padding(.top, self.model.contentPaddings.top)
36+
.padding(.leading, self.model.contentPaddings.leading)
37+
.padding(.trailing, self.model.contentPaddings.trailing)
38+
39+
ScrollView {
40+
self.contentBody()
41+
.padding(.leading, self.model.contentPaddings.leading)
42+
.padding(.trailing, self.model.contentPaddings.trailing)
43+
.observeSize {
44+
self.bodySize = $0
45+
}
46+
.padding(.top, self.bodyTopPadding)
47+
.padding(.bottom, self.bodyBottomPadding)
48+
}
49+
.frame(maxHeight: self.scrollViewMaxHeight)
50+
.disableScrollWhenContentFits()
51+
52+
self.contentFooter()
53+
.observeSize {
54+
self.footerSize = $0
55+
}
56+
.padding(.leading, self.model.contentPaddings.leading)
57+
.padding(.trailing, self.model.contentPaddings.trailing)
58+
.padding(.bottom, self.model.contentPaddings.bottom)
59+
}
60+
.frame(maxWidth: self.model.size.maxWidth, alignment: .leading)
61+
.background(self.model.preferredBackgroundColor.color)
62+
.background(UniversalColor.background.color)
63+
.clipShape(RoundedRectangle(
64+
cornerRadius: self.model.cornerRadius.value
65+
))
66+
.padding(.top, self.model.outerPaddings.top)
67+
.padding(.leading, self.model.outerPaddings.leading)
68+
.padding(.bottom, self.model.outerPaddings.bottom)
69+
.padding(.trailing, self.model.outerPaddings.trailing)
70+
}
71+
72+
private var bodyTopPadding: CGFloat {
73+
return self.headerSize.height > 0 ? 0 : self.model.contentPaddings.top
74+
}
75+
private var bodyBottomPadding: CGFloat {
76+
return self.footerSize.height > 0 ? 0 : self.model.contentPaddings.bottom
77+
}
78+
private var scrollViewMaxHeight: CGFloat {
79+
return self.bodySize.height + self.bodyTopPadding + self.bodyBottomPadding
80+
}
81+
}

0 commit comments

Comments
 (0)