Swiftui - in Sheet Have a Fixed Continue Button That Is Not Scrollable

SwiftUI - in sheet have a fixed continue button that is not scrollable

Here is a demo of possible approach (tuning & effects are out of scope - try to make demo code short). The idea is to inject UIView holder with button above sheet so it persist during sheet drag down (because as findings shown any dynamic offsets gives some ugly undesired shaking effects).

Tested with Xcode 12 / iOS 14

demo

            // ... your above code here

}//VStack for 3 criterias
.padding([.leading, .trailing], 20)

Spacer()

// button moved from here into below background view !!

}.background(BottomView(presentation: presentationMode) {
Button {
presentationMode.wrappedValue.dismiss()
UserDefaults.standard.set(true, forKey: "LaunchedBefore")
} label: {
Text("Continue")
.fontWeight(.medium)
.padding([.top, .bottom], 15)
.padding([.leading, .trailing], 90)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(15)
}
})
//Main VStack
}
}

struct BottomView<Content: View>: UIViewRepresentable {
@Binding var presentationMode: PresentationMode
private var content: () -> Content

init(presentation: Binding<PresentationMode>, @ViewBuilder _ content: @escaping () -> Content) {
_presentationMode = presentation
self.content = content
}

func makeUIView(context: Context) -> UIView {
let view = UIView()

DispatchQueue.main.async {
if let window = view.window {
let holder = UIView()
context.coordinator.holder = holder

// simple demo background to make it visible
holder.layer.backgroundColor = UIColor.gray.withAlphaComponent(0.5).cgColor

holder.translatesAutoresizingMaskIntoConstraints = false

window.addSubview(holder)
holder.heightAnchor.constraint(equalToConstant: 140).isActive = true
holder.bottomAnchor.constraint(equalTo: window.bottomAnchor, constant: 0).isActive = true
holder.leadingAnchor.constraint(equalTo: window.leadingAnchor, constant: 0).isActive = true
holder.trailingAnchor.constraint(equalTo: window.trailingAnchor, constant: 0).isActive = true

if let contentView = UIHostingController(rootView: content()).view {
contentView.backgroundColor = UIColor.clear
contentView.translatesAutoresizingMaskIntoConstraints = false
holder.addSubview(contentView)

contentView.topAnchor.constraint(equalTo: holder.topAnchor, constant: 0).isActive = true
contentView.bottomAnchor.constraint(equalTo: holder.bottomAnchor, constant: 0).isActive = true
contentView.leadingAnchor.constraint(equalTo: holder.leadingAnchor, constant: 0).isActive = true
contentView.trailingAnchor.constraint(equalTo: holder.trailingAnchor, constant: 0).isActive = true
}
}
}
return view
}

func updateUIView(_ uiView: UIView, context: Context) {
if !presentationMode.isPresented {
context.coordinator.holder.removeFromSuperview()
}
}

func makeCoordinator() -> Coordinator {
Coordinator()
}

class Coordinator {
var holder: UIView!

deinit {
holder.removeFromSuperview()
}
}
}

SwiftUI Issues with sheet modifier

.sheet being embedded inside a List does only open once is a known bug and I hope they fix it. Until then you have to move .sheet outside of any List.

But since the .sheet is not inside a List but inside a NavigationView, it might a good idea to try to move the .sheet outside of it.

But do not attach two .sheet two the same View, but add them the following way instead:

VStack{
NavigationView{ ... }
Text("").hidden().sheet(...)
Text("").hidden().sheet(...)
}

How to make Button in background tappable with ScrollView on top?


Version 2.0.0


I decided to updated my answer and because it can be confused if i put them in same answer, I am putting it here, to keep codes and answers much clear and easy to read. about new update, it is basically the same way but a souped-up version!


Sample Image



import SwiftUI

struct ContentView: View {

@State private var isPresented: Bool = Bool()
@State private var offsetY: CGFloat = CGFloat()
@State private var headerHeight: CGFloat = CGFloat()

var body: some View {

GeometryReader { screenGeometry in

ZStack {

Color.yellow.ignoresSafeArea()

ScrollView(showsIndicators: false) {

VStack(spacing: 0.0) {

Color.clear // Test if we are in Center! >> change Color.clear to Color.blue and uncomment down code for Capsule() to see it!
.frame(height: headerHeight)
//.overlay(Capsule().frame(height: 2.0))
.overlay( HeaderView(isPresented: $isPresented)
.background( GeometryReader { proxy in Color.clear.onAppear { headerHeight = proxy.size.height } } )
.offset(y: headerHeight + screenGeometry.safeAreaInsets.top - offsetY))

ZStack {

Color.white.cornerRadius(20.0)

VStack { ForEach((0...30), id: \.self) { item in Text("item " + item.description); Divider().padding(.horizontal) } }.padding(.top)

}
.padding(.horizontal)
.overlay( GeometryReader { proxy in Color.clear.onChange(of: proxy.frame(in: .global).minY) { newValue in offsetY = newValue } } )

}

}

}
.position(x: screenGeometry.size.width/2, y: screenGeometry.size.height/2)
.alert(isPresented: $isPresented) { Alert(title: Text("Button tapped")) }
.statusBar(hidden: true)

}
.shadow(radius: 10.0)

}

}


struct HeaderView: View {

@Binding var isPresented: Bool

var body: some View {

ZStack {

Color.clear

VStack(spacing: 20.0) {

button(color: Color(UIColor.systemTeal))

Text("Some text 1").bold()

Text("Some text 2").bold()

button(color: Color(UIColor.green))

Text("Some text 3").bold()

Text("Some text 4").bold()

button(color: Color.purple)

}

}
.padding()

}

func button(color: Color) -> some View {

return Button(action: { isPresented.toggle() }, label: {

Text("Start")
.bold()
.padding()
.shadow(radius: 10.0)
.frame(maxWidth: .infinity)
.background(color)
.foregroundColor(.white)
.cornerRadius(16)

})

}

}

Prevent dismissal of modal view controller in SwiftUI


Update for iOS 15

As per pawello2222's answer below, this is now supported by the new interactiveDismissDisabled(_:) API.

struct ContentView: View {
@State private var showSheet = false

var body: some View {
Text("Content View")
.sheet(isPresented: $showSheet) {
Text("Sheet View")
.interactiveDismissDisabled(true)
}
}
}

Pre-iOS-15 answer

I wanted to do this as well, but couldn't find the solution anywhere. The answer that hijacks the drag gesture kinda works, but not when it's dismissed by scrolling a scroll view or form. The approach in the question is less hacky also, so I investigated it further.

For my use case I have a form in a sheet which ideally could be dismissed when there's no content, but has to be confirmed through a alert when there is content.

My solution for this problem:

struct ModalSheetTest: View {
@State private var showModally = false
@State private var showSheet = false

var body: some View {
Form {
Toggle(isOn: self.$showModally) {
Text("Modal")
}
Button(action: { self.showSheet = true}) {
Text("Show sheet")
}
}
.sheet(isPresented: $showSheet) {
Form {
Button(action: { self.showSheet = false }) {
Text("Hide me")
}
}
.presentation(isModal: self.showModally) {
print("Attempted to dismiss")
}
}
}
}

The state value showModally determines if it has to be showed modally. If so, dragging it down to dismiss will only trigger the closure which just prints "Attempted to dismiss" in the example, but can be used to show the alert to confirm dismissal.

struct ModalView<T: View>: UIViewControllerRepresentable {
let view: T
let isModal: Bool
let onDismissalAttempt: (()->())?

func makeUIViewController(context: Context) -> UIHostingController<T> {
UIHostingController(rootView: view)
}

func updateUIViewController(_ uiViewController: UIHostingController<T>, context: Context) {
context.coordinator.modalView = self
uiViewController.rootView = view
uiViewController.parent?.presentationController?.delegate = context.coordinator
}

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
let modalView: ModalView

init(_ modalView: ModalView) {
self.modalView = modalView
}

func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
!modalView.isModal
}

func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
modalView.onDismissalAttempt?()
}
}
}

extension View {
func presentation(isModal: Bool, onDismissalAttempt: (()->())? = nil) -> some View {
ModalView(view: self, isModal: isModal, onDismissalAttempt: onDismissalAttempt)
}
}

This is perfect for my use case, hope it helps you or someone else out as well.

How to only disable scroll in ScrollView but not content view?

Only pass .horizontal as the scroll axis if you want the view to scroll. Otherwise, pass the empty set.

struct TestView: View {
@Binding var shouldScroll: Bool

var body: some View {
ScrollView(axes, showsIndicators: false) {
Text("Your content here")
}
}

private var axes: Axis.Set {
return shouldScroll ? .horizontal : []
}
}

Multiple sheet(isPresented:) doesn't work in SwiftUI


UPD

Starting from Xcode 12.5.0 Beta 3 (3 March 2021) this question makes no sense anymore as it is possible now to have multiple .sheet(isPresented:) or .fullScreenCover(isPresented:) in a row and the code presented in the question will work just fine.

Nevertheless I find this answer still valid as it organizes the sheets very well and makes the code clean and much more readable - you have one source of truth instead of a couple of independent booleans

The actual answer

Best way to do it, which also works for iOS 14:

enum ActiveSheet: Identifiable {
case first, second

var id: Int {
hashValue
}
}

struct YourView: View {
@State var activeSheet: ActiveSheet?

var body: some View {
VStack {
Button {
activeSheet = .first
} label: {
Text("Activate first sheet")
}

Button {
activeSheet = .second
} label: {
Text("Activate second sheet")
}
}
.sheet(item: $activeSheet) { item in
switch item {
case .first:
FirstView()
case .second:
SecondView()
}
}
}
}

Read more here: https://developer.apple.com/documentation/swiftui/view/sheet(item:ondismiss:content:)

To hide the sheet just set activeSheet = nil

Bonus:
If you want your sheet to be fullscreen, then use the very same code, but instead of .sheet write .fullScreenCover

SwiftUI: Spacer doesn't work at ScrollView

Use GeometryReader

A container view that defines its content as a function of its own size and coordinate space.
https://developer.apple.com/documentation/swiftui/geometryreader

GeometryReader { geometry in
ScrollView {
VStack() {
-----
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}

Doing so, the VStack is full-screen.


You may not neeed to use ScrollView, because you do not need to scroll to see its content.

    var body: some View {

VStack() {
Text("Title_1")
.padding(.bottom, 35.0)
Text("Title_2")
.padding(.bottom, 32.0)
Text("Title_3")
.padding(.bottom, 27.0)

Spacer()
Button(action: { print("ACTION") }) {
Text("OK")
.font(.title)
.fontWeight(.semibold)
.foregroundColor(Color.red)

}
.frame(height: 35)
.cornerRadius(8.0)
.padding(.bottom, 25.0)
}
.frame(maxWidth: .infinity)
}

But if your content's height is more than the screen height, the OK button is at the bottom, regardless. Hence, you do not need to do anything.



Related Topics



Leave a reply



Submit