I want to know if user did release the drag gesture inside View or out side, for this reason I just worked for local and it is working, I wanted finish for global, but I saw that I would be need to read the parent Size, the location and the size of child also some math work to know if the tap release was inside or out side the view, So I was not sure if there is a simpler way for this, that is why asked to know, the current view is just a simple Rec, but it would needed more math work if it was Circle or what I should do with a custom path Shape? I cannot hard coded multiple if for a custom path, which that condition would not usable for deferent custom path! So what is the logical and best way for this job?
PS: My focus is not finding answer for global coordinateSpace, I can do it by myself, but that would not useful if my view was Circle, or a custom path! I want find out a basic and general way for using to all cases, instead finding answer just for special condition.
struct ContentView: View {
#State private var isPressing: Bool = Bool()
let frameOfView: CGSize = CGSize(width: 300.0, height: 300.0)
var body: some View {
Color.red
.overlay(Color.yellow.frame(width: frameOfView.width, height: frameOfView.height).gesture(gesture))
}
private var gesture: some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .local)
.onEnded() { value in
print("isInside =", isInside(frame: frameOfView, location: value.location, coordinateSpace: .local))
}
}
func isInside(frame: CGSize, location: CGPoint, coordinateSpace: CoordinateSpace) -> Bool {
if (coordinateSpace.isLocal) {
return (location.x >= 0.0) && (location.y >= 0.0) && (location.x <= frame.width) && (location.y <= frame.height)
}
else if (coordinateSpace.isGlobal) {
return false // under edit!
}
else {
return false // under edit!
}
}
}
You could pass in the Shape of the view you are using, and use that to determine the path for the shape of the view. You can then test if the last point dragged was inside or outside of this shape.
This is usually just a Rectangle(), aka a rectangular view, so in my example there is even a convenience initializer if you don't want to provide this every time.
Code:
struct TapReleaseDetector<ContentShape: Shape, Content: View>: View {
typealias TapAction = (Bool) -> Void
private let shape: ContentShape
private let content: () -> Content
private let action: TapAction
#State private var path: Path?
init(shape: ContentShape, #ViewBuilder content: #escaping () -> Content, action: #escaping TapAction) {
self.shape = shape
self.content = content
self.action = action
}
init(#ViewBuilder content: #escaping () -> Content, action: #escaping TapAction) where ContentShape == Rectangle {
self.init(shape: Rectangle(), content: content, action: action)
}
var body: some View {
content()
.background(
GeometryReader { geo in
Color.clear.onAppear {
path = shape.path(in: geo.frame(in: .local))
}
}
)
.gesture(gesture)
}
private var gesture: some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .local)
.onEnded { drag in
guard let path = path else { return }
action(path.contains(drag.location))
}
}
}
Example usage:
struct ContentView: View {
#State private var result: Bool?
#State private var opacity: Double = 0
#State private var currentId = UUID()
private var resultText: String? {
if let result = result {
return result ? "Inside" : "Outside"
} else {
return nil
}
}
var body: some View {
VStack(spacing: 30) {
Text(resultText ?? " ")
.font(.title)
.opacity(opacity)
TapReleaseDetector(shape: Circle()) {
Circle()
.fill(Color.red)
.frame(width: 300, height: 300)
} action: { isInside in
result = isInside
opacity = 1
withAnimation(.easeOut(duration: 1)) {
opacity = 0
}
let tempId = UUID()
currentId = tempId
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
guard tempId == currentId else { return }
result = nil
}
}
Text("Recent: \(resultText ?? "None")")
}
}
}
Result:
I’ve created a View extension to read its offset (inspired by https://fivestars.blog/swiftui/swiftui-share-layout-information.html):
func readOffset(in coordinateSpace: String? = nil, onChange: #escaping (CGFloat) -> Void) -> some View {
background(
GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: coordinateSpace == nil ? .global : .named(coordinateSpace)).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self, perform: onChange)
}
I’m also using Federico’s readSize function:
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geo in
Color.clear
.preference(key: SizePreferenceKey.self, value: geo.size)
})
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
The two work together to help me determine whether a child view within a scrollview is on/off-screen:
struct TestInfinityList: View {
#State var visibleItems: Set<Int> = []
#State var items: [Int] = Array(0...20)
#State var size: CGSize = .zero
var body: some View {
ScrollView(.vertical) {
ForEach(items, id: \.self) { item in
GeometryReader { geo in
VStack {
Text("Item \(item)")
}.id(item)
.readOffset(in: "scroll") { newOffset in
if !isOffscreen(when: newOffset, in: size.height) {
visibleItems.insert(item)
}
else {
visibleItems.remove(item)
}
}
}.frame(height: 300)
}
}.coordinateSpace(name: "scroll")
}
.readSize { newSize in
self.size = newSize
}
}
This is the isOffscreen function that checks for visibility:
func isOffscreen(when offset: CGFloat, in height: CGFloat) -> Bool {
if offset <= 0 && offset + height >= 0 {
return false
}
return true
}
Everything works fine. However, I’d like to optimise the code further into a single extension that checks for visibility based on the offset and size.height inputted, and also receives parameters for what to do if visible and when not i.e. move readOffset’s closure to be logic that co-exists with the extension code.
I’ve no idea whether this is feasible but thought it’s worth an ask.
You just need to create a View or ViewModifier that demands some Bindings. Note, the code below is just an example of some of the patterns you can use (e.g., an optional binding, escaping content closure), but in the form of a Stack style wrap rather than a ViewModifier (which based on the blog you know how to setup).
struct ScrollableVStack<Content: View>: View {
let content: Content
#Binding var useScrollView: Bool
#Binding var scroller: ScrollViewProxy?
#State private var staticGeo = ViewGeometry()
#State private var scrollContainerGeo = ViewGeometry()
let topFade: CGFloat
let bottomFade: CGFloat
init(_ useScrollView: Binding<Bool>,
topFade: CGFloat = 0.09,
bottomFade: CGFloat = 0.09,
_ scroller: Binding<ScrollViewProxy?> = .constant(nil),
#ViewBuilder _ content: #escaping () -> Content ) {
_useScrollView = useScrollView
_scroller = scroller
self.content = content()
self.topFade = topFade
self.bottomFade = bottomFade
}
var body: some View {
if useScrollView { scrollView }
else { VStack { staticContent } }
}
var scrollView: some View {
ScrollViewReader { scroller in
ScrollView(.vertical, showsIndicators: false) {
staticContent
.onAppear { self.scroller = scroller }
}
.geometry($scrollContainerGeo)
.fadeInOut(topFade: staticGeo.size.height * topFade,
bottomFade: staticGeo.size.height * bottomFade)
}
.onChange(of: staticGeo.size.height) { newStaticHeight in
useScrollView = newStaticHeight > scrollContainerGeo.size.height * 0.85
}
}
var staticContent: some View {
content
.geometry($staticGeo)
.padding(.top, staticGeo.size.height * topFade * 1.25)
.padding(.bottom, staticGeo.size.height * bottomFade)
}
}
I'm working on a macOS app with SwiftUI that should be able to create, arrange and delete geometric shapes on screen. The creation and dragging of shapes already works pretty well using a context menu.
import SwiftUI
class Canvas: ObservableObject {
#Published var nodes: [Node] = []
func addNode(position: CGPoint) -> Void {
nodes.append(Node(id: UUID(), position: position))
}
}
struct CanvasView: View {
#ObservedObject var canvas = Canvas()
var body: some View {
ZStack {
Color(red: 0.9, green: 0.9, blue: 0.8)
.contextMenu {
Button( action: {
self.canvas.addNode(position: CGPoint(x: 400, y: 400))
} )
{ Text("Add Node ...") }
}
ForEach(canvas.nodes) {node in
NodeView(node: node)
}
}
}
}
class Node: Identifiable, ObservableObject {
#Published var id: UUID
#Published var position: CGPoint
#Published var positionProxy: CGPoint
init (id: UUID, position: CGPoint) {
self.id = id
self.position = position
self.positionProxy = position
}
}
struct NodeView: View {
#ObservedObject var node: Node
init(node: Node) {
self.node = node
}
var draggingNode: some Gesture {
DragGesture(coordinateSpace: .global)
.onChanged { value in
self.node.position.x = value.translation.width + self.node.positionProxy.x;
self.node.position.y = -value.translation.height + self.node.positionProxy.y
}
.onEnded { value in
self.node.position.x = value.translation.width + self.node.positionProxy.x;
self.node.position.y = -value.translation.height + self.node.positionProxy.y;
self.node.positionProxy = self.node.position
}
}
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(Color.white)
.frame(width: 100, height: 100)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(lineWidth: 1)
.fill(Color.gray)
)
.position(node.position)
.gesture(draggingNode)
}
}
My problem is that all created shapes appear at the same predefined location
position: CGPoint(x: 400, y: 400)
on screen and I have to move each of them manually to its intended position. I'm looking for a way to track the cursor position during right click or the context menu position to use it as node position and be able to write something like
self.canvas.addNode(position: cursorPosition)
instead of
self.canvas.addNode(position: CGPoint(x: 400, y: 400))
Is there any functionality in Swift, preferably in SwiftUI that solves my issue?
It is currently not possible to detect mouse click location in SwiftUI. A workaround for your issue may be to use a composition of SwiftUI Gesture's.
Here is code that you can use to detect a (simple or double) left-click location:
import SwiftUI
struct ClickGesture: Gesture {
let count: Int
let coordinateSpace: CoordinateSpace
typealias Value = SimultaneousGesture<TapGesture, DragGesture>.Value
init(count: Int = 1, coordinateSpace: CoordinateSpace = .local) {
precondition(count > 0, "Count must be greater than or equal to 1.")
self.count = count
self.coordinateSpace = coordinateSpace
}
var body: SimultaneousGesture<TapGesture, DragGesture> {
TapGesture(count: count)
.simultaneously(with: DragGesture(minimumDistance: 0, coordinateSpace: coordinateSpace))
}
func onEnded(perform action: #escaping (CGPoint) -> Void) -> some Gesture {
ClickGesture(count: count, coordinateSpace: coordinateSpace)
.onEnded { (value: Value) -> Void in
guard value.first != nil else { return }
guard let startLocation = value.second?.startLocation else { return }
guard let endLocation = value.second?.location else { return }
guard ((startLocation.x-1)...(startLocation.x+1)).contains(endLocation.x),
((startLocation.y-1)...(startLocation.y+1)).contains(endLocation.y) else { return }
action(startLocation)
}
}
}
extension View {
func onClickGesture(
count: Int,
coordinateSpace: CoordinateSpace = .local,
perform action: #escaping (CGPoint) -> Void
) -> some View {
gesture(ClickGesture(count: count, coordinateSpace: coordinateSpace)
.onEnded(perform: action)
)
}
func onClickGesture(
count: Int,
perform action: #escaping (CGPoint) -> Void
) -> some View {
onClickGesture(count: count, coordinateSpace: .local, perform: action)
}
func onClickGesture(
perform action: #escaping (CGPoint) -> Void
) -> some View {
onClickGesture(count: 1, coordinateSpace: .local, perform: action)
}
}
You can use it in a very similar fashion as onTapGesture() or TapGesture:
struct MyView: View {
var body: some View {
Rectangle()
.frame(width: 600, height: 400)
.onClickGesture(count: 2) { location in
print("Double tap at location \(location)")
}
}
}
You can additionally specify a CoordinateSpace.
Using:
SwiftUI
Swift 5
tvOS
Xcode Version 11.2.1
I just want to detect a click gesture on the URLImage below
JFYI I am very new to Xcode, Swift and SwiftUI (less than 3 weeks).
URLImage(URL(string: channel.thumbnail)!,
delay: 0.25,
processors: [ Resize(size: CGSize(width:isFocused ? 300.0 : 225.0, height:isFocused ? 300.0 : 225.0), scale: UIScreen.main.scale) ],
content: {
$0.image
.resizable()
.aspectRatio(contentMode: .fill)
.clipped()
})
.frame(width: isFocused ? 300.0 : 250.0, height:isFocused ? 300.0 : 250.0)
.clipShape(Circle())
.overlay(
Circle().stroke( isFocused ? Color.white : Color.black, lineWidth: 8))
.shadow(radius:5)
.focusable(true, onFocusChange:{ (isFocused) in
withAnimation(.easeInOut(duration:0.3)){
self.isFocused = isFocused
}
if(isFocused){
self.manager.bannerChannel = self.channel
print(self.manager.bannerChannel)
self.manager.loadchannelEPG(id: self.channel.id)
}
})
.padding(20)
}
The only workaround I have found is wrapping it in a NavigationLink
or a Button but then focusable on the button doesn't run.
I found out that focusable runs on a Button/NavigationLink if I add corner radius to it but then the default click action doesn't run
Also, TapGesture is not available in tvOS
Since Gestures are available maybe there is a way using gestures that I cannot figure out.
OR
If there is a way to tap into focusable on a button (although this is the less favoured alternative since this changes the look I want to achieve).
Edit: onTapGesture() is now available starting in tvOS 16
tvOS 16
struct ContentView: View {
#FocusState var focused1
#FocusState var focused2
var body: some View {
HStack {
Text("Clickable 1")
.foregroundColor(self.focused1 ? Color.red : Color.black)
.focusable(true)
.focused($focused1)
.onTapGesture {
print("clicked 1")
}
Text("Clickable 2")
.foregroundColor(self.focused2 ? Color.red : Color.black)
.focusable(true)
.focused($focused2)
.onTapGesture {
print("clicked 2")
}
}
}
}
Previous Answer for tvOS 15 and earlier
It is possible, but not for the faint of heart. I came up with a somewhat generic solution that may help you. I hope in the next swiftUI update Apple adds a better way to attach click events for tvOS and this code can be relegated to the trash bin where it belongs.
The high level explanation of how to do this is to make a UIView that captures the focus and click events, then make a UIViewRepresentable so swiftUI can use the view. Then the view is added to the layout in a ZStack so it's hidden, but you can receive focus and respond to click events as if the user was really interacting with your real swiftUI component.
First I need to make a UIView that captures the events.
class ClickableHackView: UIView {
weak var delegate: ClickableHackDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if event?.allPresses.map({ $0.type }).contains(.select) ?? false {
delegate?.clicked()
} else {
superview?.pressesEnded(presses, with: event)
}
}
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
delegate?.focus(focused: isFocused)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var canBecomeFocused: Bool {
return true
}
}
The clickable delegate:
protocol ClickableHackDelegate: class {
func focus(focused: Bool)
func clicked()
}
Then make a swiftui extension for my view
struct ClickableHack: UIViewRepresentable {
#Binding var focused: Bool
let onClick: () -> Void
func makeUIView(context: UIViewRepresentableContext<ClickableHack>) -> UIView {
let clickableView = ClickableHackView()
clickableView.delegate = context.coordinator
return clickableView
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<ClickableHack>) {
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
class Coordinator: NSObject, ClickableHackDelegate {
private let control: ClickableHack
init(_ control: ClickableHack) {
self.control = control
super.init()
}
func focus(focused: Bool) {
control.focused = focused
}
func clicked() {
control.onClick()
}
}
}
Then I make a friendlier swiftui wrapper so I can pass in any kind of component I want to be focusable and clickable
struct Clickable<Content>: View where Content : View {
let focused: Binding<Bool>
let content: () -> Content
let onClick: () -> Void
#inlinable public init(focused: Binding<Bool>, onClick: #escaping () -> Void, #ViewBuilder content: #escaping () -> Content) {
self.content = content
self.focused = focused
self.onClick = onClick
}
var body: some View {
ZStack {
ClickableHack(focused: focused, onClick: onClick)
content()
}
}
}
Example usage:
struct ClickableTest: View {
#State var focused1: Bool = false
#State var focused2: Bool = false
var body: some View {
HStack {
Clickable(focused: self.$focused1, onClick: {
print("clicked 1")
}) {
Text("Clickable 1")
.foregroundColor(self.focused1 ? Color.red : Color.black)
}
Clickable(focused: self.$focused2, onClick: {
print("clicked 2")
}) {
Text("Clickable 2")
.foregroundColor(self.focused2 ? Color.red : Color.black)
}
}
}
}
If you'd like to avoid UIKit, you can achieve the desired solution with Long Press Gesture by setting a really small duration of pressing.
1. Only Press:
If you only need to handle the pressing action and don't need long pressing at all.
ContentView()
.onLongPressGesture(minimumDuration: 0.01, pressing: { _ in }) {
print("pressed")
}
2. Press and Long press:
If you need to handle both pressing and Long pressing.
var longPress: some Gesture {
LongPressGesture(minimumDuration: 0.5)
.onEnded { _ in
print("longpress")
}
}
ContentView()
.highPriorityGesture(longPress)
.onLongPressGesture(minimumDuration: 0.01, pressing: { _ in }) {
print("press")
}
I'm trying to recreate a Modal just like Safari in iOS13 in SwiftUI:
Here's what it looks like:
Does anyone know if this is possible in SwiftUI? I want to show a small half modal, with the option to drag to fullscreen, just like the sharing sheet.
Any advice is much appreciated!
In Swift 5.5 iOS 15+ and Mac Catalyst 15+ there is a
There is a new solution with adaptiveSheetPresentationController
https://developer.apple.com/documentation/uikit/uipopoverpresentationcontroller/3810055-adaptivesheetpresentationcontrol?changes=__4
#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()
}
}
iOS 16 Beta
In iOS 16 Beta Apple provides a pure SwiftUI solution for a Half-Modal.
.sheet(isPresented: $showSettings) {
SettingsView()
.presentationDetents:(
[.medium, .large],
selection: $settingsDetent
)
}
You can also add custom detents and specify the percentages
static func custom<D>(D.Type) -> PresentationDetent
//A custom detent with a calculated height.
static func fraction(CGFloat) -> PresentationDetent
//A custom detent with the specified fractional height.
static func height(CGFloat) -> PresentationDetent
//A custom detent with the specified height.
Example:
extension PresentationDetent {
static let bar = Self.fraction(0.2)
}
.sheet(isPresented: $showSettings) {
SettingsView()
.presentationDetents:([.bar])
}
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
iOS 16+
It looks like half sheet is finally supported in iOS 16.
To manage the size of sheet we can use PresentationDetent and specifically presentationDetents(_:selection:)
Here's an example from the documentation:
struct ContentView: View {
#State private var showSettings = false
#State private var settingsDetent = PresentationDetent.medium
var body: some View {
Button("View Settings") {
showSettings = true
}
.sheet(isPresented: $showSettings) {
SettingsView()
.presentationDetents:(
[.medium, .large],
selection: $settingsDetent
)
}
}
}
Note that if you provide more that one detent, people can drag the sheet to resize it.
Here are possible values for PresentationDetent:
large
medium
fraction(CGFloat)
height(CGFloat)
custom<D>(D.Type)
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
}
}
}
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()
}
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)
})
)
}
}
I have written a SwiftUI package which includes custom iOS 13 like half modal and its buttons.
GitHub repo: https://github.com/ViktorMaric/HalfModal
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...
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
}
}
}
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)
}
}
>>Update from the WWDC22
You can create half modals or small modals just using this tutorial at the minute 02:40 . It was one of the impressive way to resize the Modal without using any complex code. Just caring about the presentation.
Link video : enter link description here
Let's get from the usage :
.sheet(isPresented : yourbooleanvalue) {
//place some content inside
Text("test")
.presentationDetents([.medium,.large])
}
in this way you set a Modal that can be medium at the start and be dragged up to be large. But you can also use, .small attribute inside of this array of dimensions. I think it was the shortest path and the most use friendly. Now this method saved me life from thousand of lines of code.
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)
}
}
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
}
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)
}
}
}