SwiftUI - Half modal? - swift

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)
}
}
}

Related

How can I know inside or outside tap release on a view/Shape in SwiftUI?

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:

A view extension that runs conditional code based on its GeometryReader results

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)
}
}

Disable animation when a view appears in SwiftUI

I got a problem while trying to display a custom loading view in SwiftUI.
I created a custom struct view OrangeActivityIndicator:
struct OrangeActivityIndicator: View {
var style = StrokeStyle(lineWidth: 6, lineCap: .round)
#State var animate = false
let orangeColor = Color.orOrangeColor
let orangeColorOpaque = Color.orOrangeColor.opacity(0.5)
init(lineWidth: CGFloat = 6) {
style.lineWidth = lineWidth
}
var body: some View {
ZStack {
Circle()
.trim(from: 0, to: 0.7)
.stroke(
AngularGradient(gradient: .init(colors: [orangeColor, orangeColorOpaque]), center: .center), style: style
)
.rotationEffect(Angle(degrees: animate ? 360 : 0))
.animation(Animation.linear(duration: 0.7).repeatForever(autoreverses: false))
}.onAppear() {
self.animate.toggle()
}
}
}
I use it inside different screens or views, my problem is that it appears weirdly, for example in CampaignsView of the app I display it when the server call is in progress.
struct CampaignsView: View {
#ObservedObject var viewModel: CampaignsViewModel
var body: some View {
NavigationView {
ZStack {
VStack(spacing: 0) {
CustomNavigationBar(campaignsNumber: viewModel.cardCampaigns.count)
.padding([.leading, .trailing], 24)
.frame(height: 25)
CarouselView(x: $viewModel.x, screen: viewModel.screen, op: $viewModel.op, count: $viewModel.index, cardCampaigns: $viewModel.cardCampaigns).frame(height: 240)
CampaignDescriptionView(idx: viewModel.index, cardCampaigns: viewModel.cardCampaigns)
.padding([.leading, .trailing], 24)
Spacer()
}
.onAppear {
self.viewModel.getCombineCampaigns()
}
if viewModel.isLoading {
OrangeActivityIndicator()
.frame(width: 40, height: 40)
}
}
.padding(.top, 34)
.background(Color.orBackgroundGrayColor.edgesIgnoringSafeArea(.all))
.navigationBarHidden(true)
}
}
}
The Indicator itself is correctly spinning, the problem is when it appears, it appears as a translation animation coming from the bottom to the middle of the screen. This is my viewModel with the server call and isLoading property:
class CampaignsViewModel: ObservableObject {
#Published var index: Int = 0
#Published var cardCampaigns: [CardCampaign] = [CardCampaign]()
#Published var isLoading: Bool = false
var cancellable: AnyCancellable?
func getCombineCampaigns() {
self.isLoading = true
let campaignLoader = CampaignLoader()
cancellable = campaignLoader.getCampaigns()
.receive(on: DispatchQueue.main)
//Handle Events operator is used for debugging.
.handleEvents(receiveSubscription: { print("Receive subscription: \($0)") },
receiveOutput: { print("Receive output: \($0)") },
receiveCompletion: { print("Receive completion: \($0)") },
receiveCancel: { print("Receive cancel") },
receiveRequest: { print("Receive request: \($0)") })
.sink { completion in
switch completion {
case .finished:
break
case .failure(let error):
print(error)
}
} receiveValue: { campaignResult in
self.isLoading = false
guard let campaignsList = campaignResult.content else {
return
}
self.cardCampaigns = campaignsList.map { campaign in
return CardCampaign(campaign: campaign)
}
self.moveToFirstCard()
}
}
}
Use animation with linked related state
var body: some View {
ZStack {
Circle()
.trim(from: 0, to: 0.7)
.stroke(
AngularGradient(gradient: .init(colors: [orangeColor, orangeColorOpaque]), center: .center), style: style
)
.rotationEffect(Angle(degrees: animate ? 360 : 0))
.animation(Animation.linear(duration: 0.7)
.repeatForever(autoreverses: false),
value: animate) // << here !!
}.onAppear() {
self.animate.toggle()
}
}
SwiftUI 3
(iOS 15)
use the new API
.animation(.default, value: isAppeared)
SwiftUI 1, 2
Like below, when I first load a view, the button was animated.
So, I used the GeometryReader.
The animation is set to nil while the coordinates of the dummy are changing.
RecordView.swift
struct RecordView: View {
var body: some View {
Button(action: { }) {
Color(.red)
.frame(width: 38, height: 38)
.cornerRadius(6)
.padding(34)
.overlay(Circle().stroke(Color(.red), lineWidth: 8))
}
.buttonStyle(ButtonStyle1()) // 👈 customize the button.
}
}
ButtonStyle.swift
import SwiftUI
struct ButtonStyle1: ButtonStyle {
// In my case, the #State isn't worked, so I used class.
#ObservedObject private var data = ButtonStyle1Data()
func makeBody(configuration: Self.Configuration) -> some View {
ZStack {
// Dummy for tracking the view status
GeometryReader {
Color.clear
.preference(key: FramePreferenceKey.self, value: $0.frame(in: .global))
}
.frame(width: 0, height: 0)
.onPreferenceChange(FramePreferenceKey.self) { frame in
guard !data.isAppeared else { return }
// ⬇️ This is the key
data.isLoaded = 0 <= frame.origin.x
}
// Content View
configuration.label
.opacity(configuration.isPressed ? 0.5 : 1)
.scaleEffect(configuration.isPressed ? 0.92 : 1)
.animation(data.isAppeared ? .easeInOut(duration: 0.18) : nil)
}
}
}
ButtonStyleData.swift
final class ButtonStyle1Data: ObservableObject {
#Published var isAppeared = false
}
FramePreferenceKey.swift
struct FramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
}
Result

Taking screenshot doesn't work when using #State in SwiftUI

I am trying to take a screenshot of the selected area using CGRect. It works fine if I don't use #State variable. But I need to use #State variable too.
Here is my code...
struct ScreenShotTest: View {
#State var abc = 0 //Works well if I remove the line
var body: some View {
Button(action: {
let image = self.takeScreenshot(theRect: CGRect(x: 0, y: 0, width: 200, height: 100))
print(image)
}) {
Text("Take Screenshot")
.padding(.all, 10)
.background(Color.blue)
.foregroundColor(.white)
}
}
}
extension UIView {
var renderedImage: UIImage {
UIGraphicsBeginImageContextWithOptions(self.bounds.size, false, UIScreen.main.scale)
let context: CGContext = UIGraphicsGetCurrentContext()!
self.layer.render(in: context)
let capturedImage: UIImage = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
return capturedImage
}
}
extension View {
func takeScreenshot(theRect: CGRect) -> UIImage {
let window = UIWindow(frame: theRect)
let hosting = UIHostingController(rootView: self)
hosting.view.frame = window.frame
window.addSubview(hosting.view)
window.makeKeyAndVisible()
return hosting.view.renderedImage
}
}
I ran into this problem too, but with an EnvironmentObject. I solved it by declaring the EnvironmentObject in an outer view and passing it as a plain variable to the inner view that was crashing when I used it. This is pared way down from my real code, but it should convey the idea.
struct Sample: View {
#State private var isSharePresented: Bool = false
#EnvironmentObject var model: Model
static var screenshot: UIImage?
var body: some View {
GeometryReader { geometry in
// Hack to get a black background.
ZStack {
Spacer()
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(Color.black)
.edgesIgnoringSafeArea(.all)
VStack {
CompletionContent(model: Model.theModel)
Spacer()
Button(action: {
model.advanceToNext()
}) {
Text("Continue", comment: "Dismiss this view")
.font(.headline)
.foregroundColor(.init(UIColor.link))
}
HStack {
Spacer()
Button(action: {
Sample.screenshot = self.takeScreenshot(theRect: (geometry.frame(in: .global)))
self.isSharePresented = true
}) {
VStack {
Image("blank")
Image(systemName: "square.and.arrow.up")
.resizable()
.frame(width: 20, height: 30)
}
}
}
Spacer()
}
}
}
.sheet(isPresented: $isSharePresented, onDismiss: {
print("Dismiss")
}, content: {
ActivityViewController(screenshot: Sample.screenshot!)
})
}
}
struct SampleContent : View {
var model: Model
var body: some View {
Text("Content that uses the model variable goes here")
}
}
extension UIView {
var renderedImage: UIImage {
UIGraphicsBeginImageContextWithOptions(self.bounds.size, false, UIScreen.main.scale)
let context: CGContext = UIGraphicsGetCurrentContext()!
self.layer.render(in: context)
let capturedImage: UIImage = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
return capturedImage
}
}
extension View {
func takeScreenshot(theRect: CGRect) -> UIImage {
let window = UIWindow(frame: theRect)
let hosting = UIHostingController(rootView: self)
hosting.view.frame = window.frame
window.addSubview(hosting.view)
window.makeKeyAndVisible()
return hosting.view.renderedImage
}
}
struct ActivityViewController: UIViewControllerRepresentable {
var screenshot: UIImage
var applicationActivities: [UIActivity]? = nil
func makeUIViewController(
context: UIViewControllerRepresentableContext<ActivityViewController>)
-> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: [screenshot], applicationActivities: applicationActivities)
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext<ActivityViewController>) {
}
}

How to convert a View (not UIView) to an image?

Similar to this thread: How to convert a UIView to an image.
I would like to convert a SwiftUI View rather than a UIView to an image.
Although SwiftUI does not provide a direct method to convert a view into an image, you still can do it. It is a little bit of a hack, but it works just fine.
In the example below, the code captures the image of two VStacks whenever they are tapped. Their contents are converted into a UIImage (that you can later save to a file if you need). In this case, I am just displaying it below.
Note that the code can be improved, but it provides the basics to get you started. I use GeometryReader to get the coordinates of the VStack to capture, but it could be improved with Preferences to make it more robust. Check the links provided, if you need to learn more about it.
Also, in order to convert an area of the screen to an image, we do need a UIView. The code uses UIApplication.shared.windows[0].rootViewController.view to get the top view, but depending on your scenario you may need to get it from somewhere else.
Good luck!
And this is the code (tested on iPhone Xr simulator, Xcode 11 beta 4):
import SwiftUI
extension UIView {
func asImage(rect: CGRect) -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: rect)
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
}
}
struct ContentView: View {
#State private var rect1: CGRect = .zero
#State private var rect2: CGRect = .zero
#State private var uiimage: UIImage? = nil
var body: some View {
VStack {
HStack {
VStack {
Text("LEFT")
Text("VIEW")
}
.padding(20)
.background(Color.green)
.border(Color.blue, width: 5)
.background(RectGetter(rect: $rect1))
.onTapGesture { self.uiimage = UIApplication.shared.windows[0].rootViewController?.view.asImage(rect: self.rect1) }
VStack {
Text("RIGHT")
Text("VIEW")
}
.padding(40)
.background(Color.yellow)
.border(Color.green, width: 5)
.background(RectGetter(rect: $rect2))
.onTapGesture { self.uiimage = UIApplication.shared.windows[0].rootViewController?.view.asImage(rect: self.rect2) }
}
if uiimage != nil {
VStack {
Text("Captured Image")
Image(uiImage: self.uiimage!).padding(20).border(Color.black)
}.padding(20)
}
}
}
}
struct RectGetter: View {
#Binding var rect: CGRect
var body: some View {
GeometryReader { proxy in
self.createView(proxy: proxy)
}
}
func createView(proxy: GeometryProxy) -> some View {
DispatchQueue.main.async {
self.rect = proxy.frame(in: .global)
}
return Rectangle().fill(Color.clear)
}
}
Solution
Here is a possible solution that uses a UIHostingController that is inserted in the background of the rootViewController:
func convertViewToData<V>(view: V, size: CGSize, completion: #escaping (Data?) -> Void) where V: View {
guard let rootVC = UIApplication.shared.windows.first?.rootViewController else {
completion(nil)
return
}
let imageVC = UIHostingController(rootView: view.edgesIgnoringSafeArea(.all))
imageVC.view.frame = CGRect(origin: .zero, size: size)
DispatchQueue.main.async {
rootVC.view.insertSubview(imageVC.view, at: 0)
let uiImage = imageVC.view.asImage(size: size)
imageVC.view.removeFromSuperview()
completion(uiImage.pngData())
}
}
You also need a modified version of the asImage extension proposed here by kontiki (setting UIGraphicsImageRendererFormat is necessary as new devices can have 2x or 3x scale):
extension UIView {
func asImage(size: CGSize) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = 1
return UIGraphicsImageRenderer(size: size, format: format).image { context in
layer.render(in: context.cgContext)
}
}
}
Usage
Assuming you have some test view:
var testView: some View {
ZStack {
Color.blue
Circle()
.fill(Color.red)
}
}
you can convert this View to Data which can be used to return an Image (or UIImage):
convertViewToData(view: testView, size: CGSize(width: 300, height: 300)) {
guard let imageData = $0, let uiImage = UIImage(data: imageData) else { return }
return Image(uiImage: uiImage)
}
The Data object can also be saved to file, shared...
Demo
struct ContentView: View {
#State var imageData: Data?
var body: some View {
VStack {
testView
.frame(width: 50, height: 50)
if let imageData = imageData, let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
}
}
.onAppear {
convertViewToData(view: testView, size: .init(width: 300, height: 300)) {
imageData = $0
}
}
}
var testView: some View {
ZStack {
Color.blue
Circle()
.fill(Color.red)
}
}
}
Following kontiki answer, here is the Preferences way
import SwiftUI
struct ContentView: View {
#State private var uiImage: UIImage? = nil
#State private var rect1: CGRect = .zero
#State private var rect2: CGRect = .zero
var body: some View {
VStack {
HStack {
VStack {
Text("LEFT")
Text("VIEW")
}
.padding(20)
.background(Color.green)
.border(Color.blue, width: 5)
.getRect($rect1)
.onTapGesture {
self.uiImage = self.rect1.uiImage
}
VStack {
Text("RIGHT")
Text("VIEW")
}
.padding(40)
.background(Color.yellow)
.border(Color.green, width: 5)
.getRect($rect2)
.onTapGesture {
self.uiImage = self.rect2.uiImage
}
}
if uiImage != nil {
VStack {
Text("Captured Image")
Image(uiImage: self.uiImage!).padding(20).border(Color.black)
}.padding(20)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
extension CGRect {
var uiImage: UIImage? {
UIApplication.shared.windows
.filter{ $0.isKeyWindow }
.first?.rootViewController?.view
.asImage(rect: self)
}
}
extension View {
func getRect(_ rect: Binding<CGRect>) -> some View {
self.modifier(GetRect(rect: rect))
}
}
struct GetRect: ViewModifier {
#Binding var rect: CGRect
var measureRect: some View {
GeometryReader { proxy in
Rectangle().fill(Color.clear)
.preference(key: RectPreferenceKey.self, value: proxy.frame(in: .global))
}
}
func body(content: Content) -> some View {
content
.background(measureRect)
.onPreferenceChange(RectPreferenceKey.self) { (rect) in
if let rect = rect {
self.rect = rect
}
}
}
}
extension GetRect {
struct RectPreferenceKey: PreferenceKey {
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
value = nextValue()
}
typealias Value = CGRect?
static var defaultValue: CGRect? = nil
}
}
extension UIView {
func asImage(rect: CGRect) -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: rect)
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
}
}
I came up with a solution when you can save to UIImage a SwiftUI View that is not on the screen.
The solution looks a bit weird, but works fine.
First create a class that serves as connection between UIHostingController and your SwiftUI. In this class, define a function that you can call to copy your "View's" image. After you do this, simply "Publish" new value to update your views.
class Controller:ObservableObject {
#Published var update=false
var img:UIImage?
var hostingController:MySwiftUIViewHostingController?
init() {
}
func copyImage() {
img=hostingController?.copyImage()
update=true
}
}
Then wrap your SwiftUI View that you want to copy via UIHostingController
class MySwiftUIViewHostingController: UIHostingController<TestView> {
override func viewDidLoad() {
super.viewDidLoad()
}
func copyImage()->UIImage {
let renderer = UIGraphicsImageRenderer(bounds: self.view.bounds)
return renderer.image(actions: { (c) in
self.view.layer.render(in: c.cgContext)
})
}
}
The copyImage() function returns the controller's view as UIImage
Now you need to present UIHostingController:
struct MyUIViewController:UIViewControllerRepresentable {
#ObservedObject var cntrl:Controller
func makeUIViewController(context: Context) -> MySwiftUIViewHostingController {
let controller=MySwiftUIViewHostingController(rootView: TestView())
cntrl.hostingController=controller
return controller
}
func updateUIViewController(_ uiViewController: MySwiftUIViewHostingController, context: Context) {
}
}
And the rest as follows:
struct TestView:View {
var body: some View {
VStack {
Text("Title")
Image("img2")
.resizable()
.aspectRatio(contentMode: .fill)
Text("foot note")
}
}
}
import SwiftUI
struct ContentView: View {
#ObservedObject var cntrl=Controller()
var body: some View {
ScrollView {
VStack {
HStack {
Image("img1")
.resizable()
.scaledToFit()
.border(Color.black, width: 2.0)
.onTapGesture(count: 2) {
print("tap registered")
self.cntrl.copyImage()
}
Image("img1")
.resizable()
.scaledToFit()
.border(Color.black, width: 2.0)
}
TextView()
ImageCopy(cntrl: cntrl)
.border(Color.red, width: 2.0)
TextView()
TextView()
TextView()
TextView()
TextView()
MyUIViewController(cntrl: cntrl)
.aspectRatio(contentMode: .fit)
}
}
}
}
struct ImageCopy:View {
#ObservedObject var cntrl:Controller
var body: some View {
VStack {
Image(uiImage: cntrl.img ?? UIImage())
.resizable()
.frame(width: 200, height: 200, alignment: .center)
}
}
}
struct TextView:View {
var body: some View {
VStack {
Text("Bla Bla Bla Bla Bla ")
Text("Bla Bla Bla Bla Bla ")
Text("Bla Bla Bla Bla Bla ")
Text("Bla Bla Bla Bla Bla ")
Text("Bla Bla Bla Bla Bla ")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You need img1 and img2 (the one that gets copied). I put everything into a scrollview so that one can see that the image copies fine even when not on the screen.