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
// ... 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!
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
Safe to Signal Semaphore Before Deinitialization Just in Case
Preventing a Coredata Crash for Upgrading Users
Swift Combine How to Skip an Event
How to Generate a Binding for Each Array Element
Can You Override Nsdateformatter 12 VS 24 Hour Time Format Without Using a Custom Dateformat
Difference Between Type Method and Type Instance Method, etc
How to Change the Current Day's Hours and Minutes in Swift
Swift: How to Convert a String to Uint8 Array
Missing Argument for Parameter 'From' in Call When Creating Instance of Codable Class
How to Increase (Animate) the Width of the Square on Both Ends
Swiftui Coordinator Not Updating the Containing View's Property
Opposite of _Conversion in Swift to Assign to a Value of a Different Type