SwiftUI - Half modal?
SwiftXcodeSwiftuiIos13UiactivityviewcontrollerSwift Problem Overview
Swift Solutions
Solution 1 - Swift
I've written a Swift Package that includes a custom modifier that allows you to use the half modal sheet.
Here is the link: https://github.com/AndreaMiotto/PartialSheet
Feel free to use it or to contribute
Solution 2 - Swift
In Swift 5.5 iOS 15+ and Mac Catalyst 15+ there is a
There is a new solution with adaptiveSheetPresentationController
@available(iOS 15.0, *)
struct CustomSheetParentView: View {
@State private var isPresented = false
var body: some View {
VStack{
Button("present sheet", action: {
isPresented.toggle()
}).adaptiveSheet(isPresented: $isPresented, detents: [.medium()], smallestUndimmedDetentIdentifier: .large){
Rectangle()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.foregroundColor(.clear)
.border(Color.blue, width: 3)
.overlay(Text("Hello, World!").frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
isPresented.toggle()
}
)
}
}
}
}
@available(iOS 15.0, *)
struct AdaptiveSheet<T: View>: ViewModifier {
let sheetContent: T
@Binding var isPresented: Bool
let detents : [UISheetPresentationController.Detent]
let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
let prefersScrollingExpandsWhenScrolledToEdge: Bool
let prefersEdgeAttachedInCompactHeight: Bool
init(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> T) {
self.sheetContent = content()
self.detents = detents
self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
self._isPresented = isPresented
}
func body(content: Content) -> some View {
ZStack{
content
CustomSheet_UI(isPresented: $isPresented, detents: detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge: prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: {sheetContent}).frame(width: 0, height: 0)
}
}
}
@available(iOS 15.0, *)
extension View {
func adaptiveSheet<T: View>(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> T)-> some View {
modifier(AdaptiveSheet(isPresented: isPresented, detents : detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge: prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: content))
}
}
@available(iOS 15.0, *)
struct CustomSheet_UI<Content: View>: UIViewControllerRepresentable {
let content: Content
@Binding var isPresented: Bool
let detents : [UISheetPresentationController.Detent]
let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
let prefersScrollingExpandsWhenScrolledToEdge: Bool
let prefersEdgeAttachedInCompactHeight: Bool
init(isPresented: Binding<Bool>, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> Content) {
self.content = content()
self.detents = detents
self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
self._isPresented = isPresented
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> CustomSheetViewController<Content> {
let vc = CustomSheetViewController(coordinator: context.coordinator, detents : detents, smallestUndimmedDetentIdentifier: smallestUndimmedDetentIdentifier, prefersScrollingExpandsWhenScrolledToEdge: prefersScrollingExpandsWhenScrolledToEdge, prefersEdgeAttachedInCompactHeight: prefersEdgeAttachedInCompactHeight, content: {content})
return vc
}
func updateUIViewController(_ uiViewController: CustomSheetViewController<Content>, context: Context) {
if isPresented{
uiViewController.presentModalView()
}else{
uiViewController.dismissModalView()
}
}
class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
var parent: CustomSheet_UI
init(_ parent: CustomSheet_UI) {
self.parent = parent
}
//Adjust the variable when the user dismisses with a swipe
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
if parent.isPresented{
parent.isPresented = false
}
}
}
}
@available(iOS 15.0, *)
class CustomSheetViewController<Content: View>: UIViewController {
let content: Content
let coordinator: CustomSheet_UI<Content>.Coordinator
let detents : [UISheetPresentationController.Detent]
let smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier?
let prefersScrollingExpandsWhenScrolledToEdge: Bool
let prefersEdgeAttachedInCompactHeight: Bool
private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
init(coordinator: CustomSheet_UI<Content>.Coordinator, detents : [UISheetPresentationController.Detent] = [.medium(), .large()], smallestUndimmedDetentIdentifier: UISheetPresentationController.Detent.Identifier? = .medium, prefersScrollingExpandsWhenScrolledToEdge: Bool = false, prefersEdgeAttachedInCompactHeight: Bool = true, @ViewBuilder content: @escaping () -> Content) {
self.content = content()
self.coordinator = coordinator
self.detents = detents
self.smallestUndimmedDetentIdentifier = smallestUndimmedDetentIdentifier
self.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight
self.prefersScrollingExpandsWhenScrolledToEdge = prefersScrollingExpandsWhenScrolledToEdge
super.init(nibName: nil, bundle: .main)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func dismissModalView(){
dismiss(animated: true, completion: nil)
}
func presentModalView(){
let hostingController = UIHostingController(rootView: content)
hostingController.modalPresentationStyle = .popover
hostingController.presentationController?.delegate = coordinator as UIAdaptivePresentationControllerDelegate
hostingController.modalTransitionStyle = .coverVertical
if let hostPopover = hostingController.popoverPresentationController {
hostPopover.sourceView = super.view
let sheet = hostPopover.adaptiveSheetPresentationController
//As of 13 Beta 4 if .medium() is the only detent in landscape error occurs
sheet.detents = (isLandscape ? [.large()] : detents)
sheet.largestUndimmedDetentIdentifier =
smallestUndimmedDetentIdentifier
sheet.prefersScrollingExpandsWhenScrolledToEdge =
prefersScrollingExpandsWhenScrolledToEdge
sheet.prefersEdgeAttachedInCompactHeight =
prefersEdgeAttachedInCompactHeight
sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
}
if presentedViewController == nil{
present(hostingController, animated: true, completion: nil)
}
}
/// To compensate for orientation as of 13 Beta 4 only [.large()] works for landscape
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
if UIDevice.current.orientation.isLandscape {
isLandscape = true
self.presentedViewController?.popoverPresentationController?.adaptiveSheetPresentationController.detents = [.large()]
} else {
isLandscape = false
self.presentedViewController?.popoverPresentationController?.adaptiveSheetPresentationController.detents = detents
}
}
}
@available(iOS 15.0, *)
struct CustomSheetView_Previews: PreviewProvider {
static var previews: some View {
CustomSheetParentView()
}
}
Solution 3 - Swift
You can make your own and place it inside of a zstack: https://www.mozzafiller.com/posts/swiftui-slide-over-card-like-maps-stocks
struct SlideOverCard<Content: View> : View {
@GestureState private var dragState = DragState.inactive
@State var position = CardPosition.top
var content: () -> Content
var body: some View {
let drag = DragGesture()
.updating($dragState) { drag, state, transaction in
state = .dragging(translation: drag.translation)
}
.onEnded(onDragEnded)
return Group {
Handle()
self.content()
}
.frame(height: UIScreen.main.bounds.height)
.background(Color.white)
.cornerRadius(10.0)
.shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
.offset(y: self.position.rawValue + self.dragState.translation.height)
.animation(self.dragState.isDragging ? nil : .spring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0))
.gesture(drag)
}
private func onDragEnded(drag: DragGesture.Value) {
let verticalDirection = drag.predictedEndLocation.y - drag.location.y
let cardTopEdgeLocation = self.position.rawValue + drag.translation.height
let positionAbove: CardPosition
let positionBelow: CardPosition
let closestPosition: CardPosition
if cardTopEdgeLocation <= CardPosition.middle.rawValue {
positionAbove = .top
positionBelow = .middle
} else {
positionAbove = .middle
positionBelow = .bottom
}
if (cardTopEdgeLocation - positionAbove.rawValue) < (positionBelow.rawValue - cardTopEdgeLocation) {
closestPosition = positionAbove
} else {
closestPosition = positionBelow
}
if verticalDirection > 0 {
self.position = positionBelow
} else if verticalDirection < 0 {
self.position = positionAbove
} else {
self.position = closestPosition
}
}
}
enum CardPosition: CGFloat {
case top = 100
case middle = 500
case bottom = 850
}
enum DragState {
case inactive
case dragging(translation: CGSize)
var translation: CGSize {
switch self {
case .inactive:
return .zero
case .dragging(let translation):
return translation
}
}
var isDragging: Bool {
switch self {
case .inactive:
return false
case .dragging:
return true
}
}
}
Solution 4 - Swift
As of Beta 2 Beta 3 you can't present a modal View
as .fullScreen
. It presents as .automatic -> .pageSheet
. Even once that's fixed, though, I highly doubt they will give you the drag capability there for free. It would be included in the docs already.
You can use this answer to present full screen for now. Gist here.
Then, after presentation, this is a quick and dirty example of how you can recreate that interaction.
@State var drag: CGFloat = 0.0
var body: some View {
ZStack(alignment: .bottom) {
Spacer() // Use the full space
Color.red
.frame(maxHeight: 300 + self.drag) // Whatever minimum height you want, plus the drag offset
.gesture(
DragGesture(coordinateSpace: .global) // if you use .local the frame will jump around
.onChanged({ (value) in
self.drag = max(0, -value.translation.height)
})
)
}
}
Solution 5 - Swift
Here's my naive bottom sheet which scales to its content. Without dragging but it should be relatively easy to add if needed :)
struct BottomSheet<SheetContent: View>: ViewModifier {
@Binding var isPresented: Bool
let sheetContent: () -> SheetContent
func body(content: Content) -> some View {
ZStack {
content
if isPresented {
VStack {
Spacer()
VStack {
HStack {
Spacer()
Button(action: {
withAnimation(.easeInOut) {
self.isPresented = false
}
}) {
Text("done")
.padding(.top, 5)
}
}
sheetContent()
}
.padding()
}
.zIndex(.infinity)
.transition(.move(edge: .bottom))
.edgesIgnoringSafeArea(.bottom)
}
}
}
}
extension View {
func customBottomSheet<SheetContent: View>(
isPresented: Binding<Bool>,
sheetContent: @escaping () -> SheetContent
) -> some View {
self.modifier(BottomSheet(isPresented: isPresented, sheetContent: sheetContent))
}
}
and use like below:
.customBottomSheet(isPresented: $isPickerPresented) {
DatePicker(
"time",
selection: self.$time,
displayedComponents: .hourAndMinute
)
.labelsHidden()
}
Solution 6 - Swift
I have written a SwiftUI package which includes custom iOS 13 like half modal and its buttons.
GitHub repo: https://github.com/ViktorMaric/HalfModal
Solution 7 - Swift
I think almost every iOS developer who writes anything in SwiftUI must come up against this. I certainly did, but I thought that most of the answers here were either too complex or didn't really provide what I wanted.
I've written a very simple partial sheet which is on GitHub, available as a Swift package - HalfASheet
It probably doesn't have the bells & whistles of some of the other solutions, but it does what it needs to do. Plus, writing your own is always good for understanding what's going on.
Note - A couple of things - First of all, this is very much a work-in-progress, please feel free to improve it, etc. Secondly, I've deliberately not done a .podspec as if you're developing for SwiftUI you're on iOS 13 minimum, and the Swift Packages are so much nicer in my opinion...
Solution 8 - Swift
Andre Carrera's answer is great and feel free to use this guide he provided: https://www.mozzafiller.com/posts/swiftui-slide-over-card-like-maps-stocks
I have modified the SlideOverCard structure so it uses actual device height to measure where the card is supposed to stop (you can play with bounds.height to adjust for your needs):
struct SlideOverCard<Content: View>: View {
var bounds = UIScreen.main.bounds
@GestureState private var dragState = DragState.inactive
@State var position = UIScreen.main.bounds.height/2
var content: () -> Content
var body: some View {
let drag = DragGesture()
.updating($dragState) { drag, state, transaction in
state = .dragging(translation: drag.translation)
}
.onEnded(onDragEnded)
return Group {
Handle()
self.content()
}
.frame(height: UIScreen.main.bounds.height)
.background(Color.white)
.cornerRadius(10.0)
.shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
.offset(y: self.position + self.dragState.translation.height)
.animation(self.dragState.isDragging ? nil : .interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0))
.gesture(drag)
}
private func onDragEnded(drag: DragGesture.Value) {
let verticalDirection = drag.predictedEndLocation.y - drag.location.y
let cardTopEdgeLocation = self.position + drag.translation.height
let positionAbove: CGFloat
let positionBelow: CGFloat
let closestPosition: CGFloat
if cardTopEdgeLocation <= bounds.height/2 {
positionAbove = bounds.height/7
positionBelow = bounds.height/2
} else {
positionAbove = bounds.height/2
positionBelow = bounds.height - (bounds.height/9)
}
if (cardTopEdgeLocation - positionAbove) < (positionBelow - cardTopEdgeLocation) {
closestPosition = positionAbove
} else {
closestPosition = positionBelow
}
if verticalDirection > 0 {
self.position = positionBelow
} else if verticalDirection < 0 {
self.position = positionAbove
} else {
self.position = closestPosition
}
}
}
enum DragState {
case inactive
case dragging(translation: CGSize)
var translation: CGSize {
switch self {
case .inactive:
return .zero
case .dragging(let translation):
return translation
}
}
var isDragging: Bool {
switch self {
case .inactive:
return false
case .dragging:
return true
}
}
}
Solution 9 - Swift
I was trying to do the same thing asked here, display the share sheet in a natively manner in SwiftUI without to have to implement / import a component. I've found this solution in <https://jeevatamil.medium.com/how-to-create-share-sheet-uiactivityviewcontroller-in-swiftui-cef64b26f073>
struct ShareSheetView: View {
var body: some View {
Button(action: actionSheet) {
Image(systemName: "square.and.arrow.up")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 36, height: 36)
}
}
func actionSheet() {
guard let data = URL(string: "https://www.zoho.com") else { return }
let av = UIActivityViewController(activityItems: [data], applicationActivities: nil)
UIApplication.shared.windows.first?.rootViewController?.present(av, animated: true, completion: nil)
}
}
Solution 10 - Swift
In iOS 14, Swift 5, Xcode 12.5 at least, I was able to accomplish this fairly easily by simply wrapping the the UIActivityViewController
in another view controller. It doesn't require inspecting the view hierarchy or using any 3rd party libraries. The only hackish part is asynchronously presenting the view controller, which might not even be necessary. Someone with more SwiftUI experience might be able to offer suggestions for improvement.
import Foundation
import SwiftUI
import UIKit
struct ActivityViewController: UIViewControllerRepresentable {
@Binding var shareURL: URL?
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> some UIViewController {
let containerViewController = UIViewController()
return containerViewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
guard let shareURL = shareURL, context.coordinator.presented == false else { return }
context.coordinator.presented = true
let activityViewController = UIActivityViewController(activityItems: [shareURL], applicationActivities: nil)
activityViewController.completionWithItemsHandler = { activity, completed, returnedItems, activityError in
self.shareURL = nil
context.coordinator.presented = false
if completed {
// ...
} else {
// ...
}
}
// Executing this asynchronously might not be necessary but some of my tests
// failed because the view wasn't yet in the view hierarchy on the first pass of updateUIViewController
//
// There might be a better way to test for that condition in the guard statement and execute this
// synchronously if we can be be sure updateUIViewController is invoked at least once after the view is added
DispatchQueue.main.asyncAfter(deadline: .now()) {
uiViewController.present(activityViewController, animated: true)
}
}
class Coordinator: NSObject {
let parent: ActivityViewController
var presented: Bool = false
init(_ parent: ActivityViewController) {
self.parent = parent
}
}
}
struct ContentView: View {
@State var shareURL: URL? = nil
var body: some View {
ZStack {
Button(action: { shareURL = URL(string: "https://apple.com") }) {
Text("Share")
.foregroundColor(.white)
.padding()
}
.background(Color.blue)
if shareURL != nil {
ActivityViewController(shareURL: $shareURL)
}
}
.frame(width: 375, height: 812)
}
}
Solution 11 - Swift
For a more generic solution, I have come up with the following idea: https://github.com/mtzaquia/UIKitPresentationModifier
This is a generic modifier that allows you to use UIKit
presentations within a SwiftUI
view.
From there, the world is your oyster. The only drawback is that you may need to cascade custom environment values from the presenting view into the presented view.
myPresentingView
.presentation(isPresented: $isPresented) {
MyPresentedView()
} controllerProvider: { content in
let controller = UIHostingController(rootView: content)
if #available(iOS 15, *) {
if let sheet = controller.sheetPresentationController {
sheet.preferredCornerRadius = 12
sheet.prefersGrabberVisible = true
}
}
return controller
}
Solution 12 - Swift
Works by me:
var body: some View {
ZStack {
YOURTOPVIEW()
VStack {
Spacer()
.frame(minWidth: .zero,
maxWidth: .infinity,
minHeight: .zero,
maxHeight: .infinity,
alignment: .top)
YOURBOTTOMVIEW()
.frame(minWidth: .zero,
maxWidth: .infinity,
minHeight: .zero,
maxHeight: .infinity,
alignment: .bottom)
}
}
}