How to make custom .sheet viewmodifier - swiftui

I am trying to recreate the native .sheet() view modifier in SwiftUI. When I look at the definition, I get below function, but I'm not sure where to go from there.
The .sheet somehow passes a view WITH bindings to a distant parent at the top of the view-tree, but I can't see how that is done. If you use PreferenceKey with an AnyView, you can't have bindings.
My usecase is that I want to define a sheet in a subview, but I want to activate it at a distant parent-view to avoid it interfering with other code.
func showSheet<Content>(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, #ViewBuilder content: #escaping () -> Content) -> some View where Content : View {
// What do I put here?
}

So, I ended up doing my own sheet in SwiftUI using a preferenceKey for passing the view up the view-tree, and an environmentObject for passing the binding for showing/hiding the sheet back down again.
It's a bit long-winded, but here's the gist of it:
struct HomeOverlays<Content: View>: View {
#Binding var showSheet:Bool
#State private var sheet:EquatableViewContainer = EquatableViewContainer(id: "original", view: AnyView(Text("No view")))
#State private var animatedSheet:Bool = false
#State private var dragPercentage:Double = 0 /// 1 = fully visible, 0 = fully hidden
// Content
let content: Content
init(_ showSheet: Binding<Bool>, #ViewBuilder content: #escaping () -> Content) {
self._showSheet = showSheet
self.content = content()
}
var body: some View {
GeometryReader { geometry in
ZStack {
content
.blur(radius: 5 * dragPercentage)
.opacity(1 - dragPercentage * 0.5)
.disabled(showSheet)
.scaleEffect(1 - 0.1 * dragPercentage)
.frame(width: geometry.size.width, height: geometry.size.height)
if animatedSheet {
sheet.view
.background(Color.greyB.opacity(0.5).edgesIgnoringSafeArea(.bottom))
.cornerRadius(5)
.transition(.move(edge: .bottom).combined(with: .opacity))
.dragToSnap(snapPercentage: 0.3, dragPercentage: $dragPercentage) { showSheet = false } /// Custom modifier for measuring how far the view is dragged down. If more than 30% it snaps showSheet to false, and otherwise it snaps it back up again
.edgesIgnoringSafeArea(.bottom)
}
}
.onPreferenceChange(HomeOverlaySheet.self, perform: { value in self.sheet = value } )
.onChange(of: showSheet) { show in sheetUpdate(show) }
}
}
func sheetUpdate(_ show:Bool) {
withAnimation(.easeOut(duration: 0.2)) {
self.animatedSheet = show
if show { dragPercentage = 1 } else { dragPercentage = 0 }
}
// Delay onDismiss action if removing sheet, so animation can complete
if show == false {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
sheet.action()
}
}
}
}
struct HomeOverlays_Previews: PreviewProvider {
static var previews: some View {
HomeOverlays(.constant(false)) {
Text("Home overlays")
}
}
}
// MARK: Preference key for passing view up the tree
struct HomeOverlaySheet: PreferenceKey {
static var defaultValue: EquatableViewContainer = EquatableViewContainer(id: "default", view: AnyView(EmptyView()) )
static func reduce(value: inout EquatableViewContainer, nextValue: () -> EquatableViewContainer) {
if value != nextValue() && nextValue().id != "default" {
value = nextValue()
}
}
}
// MARK: View extension for defining view somewhere in view tree
extension View {
// Change only leading view
func homeSheet<SheetView: View>(onDismiss action: #escaping () -> Void, #ViewBuilder sheet: #escaping () -> SheetView) -> some View {
let sheet = sheet()
return
self
.preference(key: HomeOverlaySheet.self, value: EquatableViewContainer(view: AnyView( sheet ), action: action ))
}
}

Related

How do I make a preferenceKey accept a View in SwiftUI?

I'm trying to build a custom NavigationView, and I'm struggling with how to implement a custom ".navigationBarItems(leading: /* insert views /, trailing: / insert Views */)". I assume I have to use a preferenceKey, but I don't know how to make it accept views.
My top menu looks something like this:
import SwiftUI
struct TopMenu<Left: View, Right: View>: View {
let leading: Left
let trailing: Right
init(#ViewBuilder left: #escaping () -> Left, #ViewBuilder right: #escaping () -> Right) {
self.leading = left()
self.trailing = right()
}
var body: some View {
VStack(spacing: 0) {
HStack {
leading
Spacer()
trailing
}.frame(height: 30, alignment: .center)
Spacer()
}
.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
}
}
struct TopMenu_Previews: PreviewProvider {
static var previews: some View {
TopMenu(left: { }, right: { })
}
}
And this is my attempt at creating a preferenceKey to update it with, where I've obviously missed something very basic:
struct TopMenuItemsLeading: PreferenceKey {
static var defaultValue:View
static func reduce(value: inout View, nextValue: () -> View) {
value = nextValue()
}
}
struct TopMenuItemsTrailing: PreferenceKey {
static var defaultValue:View
static func reduce(value: inout View, nextValue: () -> View) {
value = nextValue()
}
}
extension View {
func topMenuItems(leading: View, trailing: View) -> some View {
self.preference(key: TopMenuItemsLeading.self, value: leading)
self.preference(key: TopMenuItemsTrailing.self, value: trailing)
}
}
Ok, so there was some great partial answers in here, but none that actually achieved what I asked, which was to pass a view up the view-hierarchy using a preferenceKey. Essentially what the .navigationBarItems method is doing, but with my own custom view.
I found a solution however, so here goes (and apologies if I missed any obvious short-cuts. This IS my first time using preferenceKeys for anything):
import SwiftUI
struct TopMenu: View {
#State private var show:Bool = false
var body: some View {
VStack {
TopMenuView {
Button("Change", action: { show.toggle() })
Text("Hello world!")
.topMenuItems(leading: Image(systemName: show ? "xmark.circle" : "pencil"))
.topMenuItems(trailing: Image(systemName: show ? "pencil" : "xmark.circle"))
}
}
}
}
struct TopMenu_Previews: PreviewProvider {
static var previews: some View {
TopMenu()
}
}
/*
To emulate .navigationBarItems(leading: View, trailing: View), I need four things:
1) EquatableViewContainer - Because preferenceKeys need to be equatable to be able to update when a change occurred
2) PreferenceKeys - That use the EquatableViewContainer for both leading and trailing views
3) ViewExtenstions - That allow us to set the preferenceKeys individually or one at a time
4) TopMenu view - That we can set somewhere higher in the view hierarchy.
*/
// First, create an EquatableViewContainer we can use as preferenceKey data
struct EquatableViewContainer: Equatable {
let id = UUID().uuidString
let view:AnyView
static func == (lhs: EquatableViewContainer, rhs: EquatableViewContainer) -> Bool {
return lhs.id == rhs.id
}
}
// Second, define preferenceKeys that uses the Equatable view container
struct TopMenuItemsLeading: PreferenceKey {
static var defaultValue: EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()) )
static func reduce(value: inout EquatableViewContainer, nextValue: () -> EquatableViewContainer) {
value = nextValue()
}
}
struct TopMenuItemsTrailing: PreferenceKey {
static var defaultValue: EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()) )
static func reduce(value: inout EquatableViewContainer, nextValue: () -> EquatableViewContainer) {
value = nextValue()
}
}
// Third, create view-extensions for each of the ways to modify the TopMenu
extension View {
// Change only leading view
func topMenuItems<LView: View>(leading: LView) -> some View {
self
.preference(key: TopMenuItemsLeading.self, value: EquatableViewContainer(view: AnyView(leading)))
}
// Change only trailing view
func topMenuItems<RView: View>(trailing: RView) -> some View {
self
.preference(key: TopMenuItemsTrailing.self, value: EquatableViewContainer(view: AnyView(trailing)))
}
// Change both leading and trailing views
func topMenuItems<LView: View, TView: View>(leading: LView, trailing: TView) -> some View {
self
.preference(key: TopMenuItemsLeading.self, value: EquatableViewContainer(view: AnyView(leading)))
}
}
// Fourth, create the view for the TopMenu
struct TopMenuView<Content: View>: View {
// Content to put into the menu
let content: Content
#State private var leading:EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()))
#State private var trailing:EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()))
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content()
}
var body: some View {
VStack(spacing: 0) {
ZStack {
HStack {
leading.view
Spacer()
trailing.view
}
Text("TopMenu").fontWeight(.black)
}
.padding(EdgeInsets(top: 0, leading: 2, bottom: 5, trailing: 2))
.background(Color.gray.edgesIgnoringSafeArea(.top))
content
Spacer()
}
.onPreferenceChange(TopMenuItemsLeading.self, perform: { value in
leading = value
})
.onPreferenceChange(TopMenuItemsTrailing.self, perform: { value in
trailing = value
})
}
}
`````
The possible approach is to use AnyView, like
struct TopMenuItemsLeading: PreferenceKey {
static var defaultValue: AnyView = AnyView(EmptyView())
static func reduce(value: inout AnyView, nextValue: () -> AnyView) {
value = nextValue()
}
}
struct TopMenuItemsTrailing: PreferenceKey {
static var defaultValue: AnyView = AnyView(EmptyView())
static func reduce(value: inout AnyView, nextValue: () -> AnyView) {
value = nextValue()
}
}
extension View {
func topMenuItems<LView: View, TView: View>(leading: LView, trailing: TView) -> some View {
self
.preference(key: TopMenuItemsLeading.self, value: AnyView(leading))
.preference(key: TopMenuItemsTrailing.self, value: AnyView(trailing))
}
}
You could declare the initialiser of TopView to take Views like this:
struct TopMenu<Left: View, Right: View>: View {
let leading: Left
let trailing: Right
init(left: Left,
right: Right) {
self.leading = left
self.trailing = right
}
//etc.
And then declare the modifier similarly to how navigationBarItems modifiers are defined:
extension View {
func topMenuItems<L, T>(leading: L, trailing: T) -> some View where L : View, T : View {
VStack(alignment: .center, spacing: 0) {
TopMenu(left: leading, right: trailing)
self
}
}
func topMenuItems<L>(leading: L) -> some View where L : View {
VStack(alignment: .center, spacing: 0) {
TopMenu(left: leading, right: EmptyView())
self
}
}
func topMenuItems<T>(trailing: T) -> some View where T : View {
VStack(alignment: .center, spacing: 0) {
TopMenu(left: EmptyView(), right: trailing)
self
}
}
}

GeometryReader inside ScrollView - scroll doesn't work anymore [duplicate]

With the new ScrollViewReader, it seems possible to set the scroll offset programmatically.
But I was wondering if it is also possible to get the current scroll position?
It seems like the ScrollViewProxy only comes with the scrollTo method, allowing us to set the offset.
Thanks!
It was possible to read it and before. Here is a solution based on view preferences.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
}.coordinateSpace(name: "scroll")
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I found a version without using PreferenceKey. The idea is simple - by returning Color from GeometryReader, we can set scrollOffset directly inside background modifier.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader { proxy -> Color in
DispatchQueue.main.async {
offset = -proxy.frame(in: .named("scroll")).origin.y
}
return Color.clear
})
}.coordinateSpace(name: "scroll")
}
}
I had a similar need but with List instead of ScrollView, and wanted to know wether items in the lists are visible or not (List preloads views not yet visible, so onAppear()/onDisappear() are not suitable).
After a bit of "beautification" I ended up with this usage:
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
List(0..<100) { i in
Text("Item \(i)")
.onItemFrameChanged(listGeometry: geometry) { (frame: CGRect?) in
print("rect of item \(i): \(String(describing: frame)))")
}
}
.trackListFrame()
}
}
}
which is backed by this Swift package: https://github.com/Ceylo/ListItemTracking
The most popular answer (#Asperi's) has a limitation:
The scroll offset can be used in a function
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
which is convenient for triggering an event based on that offset.
But what if the content of the ScrollView depends on this offset (for example if it has to display it). So we need this function to update a #State.
The problem then is that each time this offset changes, the #State is updated and the body is re-evaluated. This causes a slow display.
We could instead wrap the content of the ScrollView directly in the GeometryReader so that this content can depend on its position directly (without using a State or even a PreferenceKey).
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
}
where content is (CGPoint) -> some View
We could take advantage of this to observe when the offset stops being updated, and reproduce the didEndDragging behavior of UIScrollView
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin,
perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2,
scheduler: DispatchQueue.main),
perform: didEndScrolling)
}
where offsetObserver = PassthroughSubject<CGPoint, Never>()
In the end, this gives :
struct _ScrollViewWithOffset<Content: View>: View {
private let axis: Axis.Set
private let content: (CGPoint) -> Content
private let didEndScrolling: (CGPoint) -> Void
private let offsetObserver = PassthroughSubject<CGPoint, Never>()
private let spaceName = "scrollView"
init(axis: Axis.Set = .vertical,
content: #escaping (CGPoint) -> Content,
didEndScrolling: #escaping (CGPoint) -> Void = { _ in }) {
self.axis = axis
self.content = content
self.didEndScrolling = didEndScrolling
}
var body: some View {
ScrollView(axis) {
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin, perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2, scheduler: DispatchQueue.main), perform: didEndScrolling)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.coordinateSpace(name: spaceName)
}
}
Note: the only problem I see is that the GeometryReader takes all the available width and height. This is not always desirable (especially for a horizontal ScrollView). One must then determine the size of the content to reflect it on the ScrollView.
struct ScrollViewWithOffset<Content: View>: View {
#State private var height: CGFloat?
#State private var width: CGFloat?
let axis: Axis.Set
let content: (CGPoint) -> Content
let didEndScrolling: (CGPoint) -> Void
var body: some View {
_ScrollViewWithOffset(axis: axis) { offset in
content(offset)
.fixedSize()
.overlay(GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
width = geo.size.width
}
})
} didEndScrolling: {
didEndScrolling($0)
}
.frame(width: axis == .vertical ? width : nil,
height: axis == .horizontal ? height : nil)
}
}
This will work in most cases (unless the content size changes, which I don't think is desirable). And finally you can use it like that :
struct ScrollViewWithOffsetForPreviews: View {
#State private var cpt = 0
let axis: Axis.Set
var body: some View {
NavigationView {
ScrollViewWithOffset(axis: axis) { offset in
VStack {
Color.pink
.frame(width: 100, height: 100)
Text(offset.x.description)
Text(offset.y.description)
Text(cpt.description)
}
} didEndScrolling: { _ in
cpt += 1
}
.background(Color.mint)
.navigationTitle(axis == .vertical ? "Vertical" : "Horizontal")
}
}
}

Detect touch outside Button in SwiftUI

I have a reset button that asks for confirmation first. I would like to set isSure to false is the user touches outside the component.
Can I do this from the Button component?
Here is my button:
struct ResetButton: View {
var onConfirmPress: () -> Void;
#State private var isSure: Bool = false;
var body: some View {
Button(action: {
if (self.isSure) {
self.onConfirmPress();
self.isSure.toggle();
} else {
self.isSure.toggle();
}
}) {
Text(self.isSure ? "Are you sure?" : "Reset")
}
}
}
here is one way to do it:
struct ContentView: View {
var onConfirmPress: () -> Void
#State private var isSure: Bool = false
var body: some View {
GeometryReader { geometry in
ZStack {
// a transparent rectangle under everything
Rectangle()
.frame(width: geometry.size.width, height: geometry.size.height)
.opacity(0.001) // <--- important
.layoutPriority(-1)
.onTapGesture {
self.isSure = false
print("---> onTapGesture self.isSure : \(self.isSure)")
}
Button(action: {
if (self.isSure) {
self.onConfirmPress()
}
self.isSure.toggle()
}) {
Text(self.isSure ? "Are you sure?" : "Reset").padding(10).border(Color.black)
}
}
}
}
}
Basically, we have some view, and we want a tap on its background to do something - meaning, we want to add a huge background that registers a tap. Note that .background is only offered the size of the main view, but can always set an explicit different size! If you know your size that's great, otherwise UIScreen could work...
This is hacky but seems to work!
extension View {
#ViewBuilder
private func onTapBackgroundContent(enabled: Bool, _ action: #escaping () -> Void) -> some View {
if enabled {
Color.clear
.frame(width: UIScreen.main.bounds.width * 2, height: UIScreen.main.bounds.height * 2)
.contentShape(Rectangle())
.onTapGesture(perform: action)
}
}
func onTapBackground(enabled: Bool, _ action: #escaping () -> Void) -> some View {
background(
onTapBackgroundContent(enabled: enabled, action)
)
}
}
Usage:
SomeView()
.onTapBackground(enabled: isShowingAlert) {
isShowingAlert = false
}
This can be easily changed to take a binding:
func onTapBackground(set value: Binding<Bool>) -> some View {
background(
onTapBackgroundContent(enabled: value.wrappedValue) { value.wrappedValue = false }
)
}
// later...
SomeView()
.onTapBackground(set: $isShowingAlert)

SwiftUI - PresentationButton with modal that is full screen

I am trying to implement a button that presents another scene with a "Slide from Botton" animation.
PresentationButton looked like a good candidate, so I gave it a try:
import SwiftUI
struct ContentView : View {
var body: some View {
NavigationView {
PresentationButton(destination: Green().frame(width: 1000.0)) {
Text("Click")
}.navigationBarTitle(Text("Navigation"))
}
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static var previews: some View {
Group {
ContentView()
.previewDevice("iPhone X")
.colorScheme(.dark)
ContentView()
.colorScheme(.dark)
.previewDevice("iPad Pro (12.9-inch) (3rd generation)"
)
}
}
}
#endif
And here is the result:
I want the green view to cover the whole screen, and also the modal to be not "draggable to close".
Is it possible to add modifier to PresentationButton to make it full screen, and not draggable?
I have also tried a Navigation Button, but:
- It doesn't "slide from bottom"
- It creates a "back button" on detail view, which I don't want
thanks!
Unfortunately, as of Beta 2 Beta 3, this is not possible in pure SwiftUI. You can see that Modal has no parameters for anything like UIModalPresentationStyle.fullScreen. Likewise for PresentationButton.
I suggest filing a radar.
The nearest you can currently do is something like:
#State var showModal: Bool = false
var body: some View {
NavigationView {
Button(action: {
self.showModal = true
}) {
Text("Tap me!")
}
}
.navigationBarTitle(Text("Navigation!"))
.overlay(self.showModal ? Color.green : nil)
}
Of course, from there you can add whatever transition you like in the overlay.
Although my other answer is currently correct, people probably want to be able to do this now. We can use the Environment to pass a view controller to children. Gist here
struct ViewControllerHolder {
weak var value: UIViewController?
}
struct ViewControllerKey: EnvironmentKey {
static var defaultValue: ViewControllerHolder { return ViewControllerHolder(value: UIApplication.shared.windows.first?.rootViewController ) }
}
extension EnvironmentValues {
var viewController: UIViewControllerHolder {
get { return self[ViewControllerKey.self] }
set { self[ViewControllerKey.self] = newValue }
}
}
Add an extension to UIViewController
extension UIViewController {
func present<Content: View>(style: UIModalPresentationStyle = .automatic, #ViewBuilder builder: () -> Content) {
// Must instantiate HostingController with some sort of view...
let toPresent = UIHostingController(rootView: AnyView(EmptyView()))
toPresent.modalPresentationStyle = style
// ... but then we can reset rootView to include the environment
toPresent.rootView = AnyView(
builder()
.environment(\.viewController, ViewControllerHolder(value: toPresent))
)
self.present(toPresent, animated: true, completion: nil)
}
}
And whenever we need it, use it:
struct MyView: View {
#Environment(\.viewController) private var viewControllerHolder: ViewControllerHolder
private var viewController: UIViewController? {
self.viewControllerHolder.value
}
var body: some View {
Button(action: {
self.viewController?.present(style: .fullScreen) {
MyView()
}
}) {
Text("Present me!")
}
}
}
[EDIT] Although it would be preferable to do something like #Environment(\.viewController) var viewController: UIViewController? this leads to a retain cycle. Therefore, you need to use the holder.
Xcode 12.0 - SwiftUI 2 - iOS 14
Now possible. Use fullScreenCover() modifier.
var body: some View {
Button("Present!") {
self.isPresented.toggle()
}
.fullScreenCover(isPresented: $isPresented, content: FullScreenModalView.init)
}
Hacking With Swift
This version fixes the compile error present in XCode 11.1 as well as ensures that controller is presented in the style that is passed in.
import SwiftUI
struct ViewControllerHolder {
weak var value: UIViewController?
}
struct ViewControllerKey: EnvironmentKey {
static var defaultValue: ViewControllerHolder {
return ViewControllerHolder(value: UIApplication.shared.windows.first?.rootViewController)
}
}
extension EnvironmentValues {
var viewController: UIViewController? {
get { return self[ViewControllerKey.self].value }
set { self[ViewControllerKey.self].value = newValue }
}
}
extension UIViewController {
func present<Content: View>(style: UIModalPresentationStyle = .automatic, #ViewBuilder builder: () -> Content) {
let toPresent = UIHostingController(rootView: AnyView(EmptyView()))
toPresent.modalPresentationStyle = style
toPresent.rootView = AnyView(
builder()
.environment(\.viewController, toPresent)
)
self.present(toPresent, animated: true, completion: nil)
}
}
To use this version, the code is unchanged from the previous version.
struct MyView: View {
#Environment(\.viewController) private var viewControllerHolder: UIViewController?
private var viewController: UIViewController? {
self.viewControllerHolder.value
}
var body: some View {
Button(action: {
self.viewController?.present(style: .fullScreen) {
MyView()
}
}) {
Text("Present me!")
}
}
}
My solution for this (which you can easily extend to allow other params on the presented sheets to be tweaked) is to just subclass UIHostingController
//HSHostingController.swift
import Foundation
import SwiftUI
class HSHostingControllerParams {
static var nextModalPresentationStyle:UIModalPresentationStyle?
}
class HSHostingController<Content> : UIHostingController<Content> where Content : View {
override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
if let nextStyle = HSHostingControllerParams.nextModalPresentationStyle {
viewControllerToPresent.modalPresentationStyle = nextStyle
HSHostingControllerParams.nextModalPresentationStyle = nil
}
super.present(viewControllerToPresent, animated: flag, completion: completion)
}
}
use HSHostingController instead of UIHostingController in your scene delegate
like so:
// Use a HSHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
//This is the only change from the standard boilerplate
window.rootViewController = HSHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
then just tell the HSHostingControllerParams class what presentation style you want before triggering a sheet
.navigationBarItems(trailing:
HStack {
Button("About") {
HSHostingControllerParams.nextModalPresentationStyle = .fullScreen
self.showMenuSheet.toggle()
}
}
)
Passing the params via the class singleton feels a little 'dirty', but in practice - you would have to create a pretty obscure scenario for this not to work as expected.
You could mess around with environment variables and the like (as other answers have done) - but to me, the added complication isn't worth the purity.
update: see this gist for extended solution with additional capabilities
So I was struggling with that and I didn't like the overlay feature nor the ViewController wrapped version since it gave me some memory bug and I am very new to iOS and only know SwiftUI and no UIKit.
I developed credits the following with just SwiftUI which is probably what an overlay does but for my purposes it is much more flexible:
struct FullscreenModalView<Presenting, Content>: View where Presenting: View, Content: View {
#Binding var isShowing: Bool
let parent: () -> Presenting
let content: () -> Content
#inlinable public init(isShowing: Binding<Bool>, parent: #escaping () -> Presenting, #ViewBuilder content: #escaping () -> Content) {
self._isShowing = isShowing
self.parent = parent
self.content = content
}
var body: some View {
GeometryReader { geometry in
ZStack {
self.parent().zIndex(0)
if self.$isShowing.wrappedValue {
self.content()
.background(Color.primary.colorInvert())
.edgesIgnoringSafeArea(.all)
.frame(width: geometry.size.width, height: geometry.size.height)
.transition(.move(edge: .bottom))
.zIndex(1)
}
}
}
}
}
Adding an extension to View:
extension View {
func modal<Content>(isShowing: Binding<Bool>, #ViewBuilder content: #escaping () -> Content) -> some View where Content: View {
FullscreenModalView(isShowing: isShowing, parent: { self }, content: content)
}
}
Usage:
Use a custom view and pass the showModal variable as a Binding<Bool> to dismiss the modal from the view itself.
struct ContentView : View {
#State private var showModal: Bool = false
var body: some View {
ZStack {
Button(action: {
withAnimation {
self.showModal.toggle()
}
}, label: {
HStack{
Image(systemName: "eye.fill")
Text("Calibrate")
}
.frame(width: 220, height: 120)
})
}
.modal(isShowing: self.$showModal, content: {
Text("Hallo")
})
}
}
I hope this helps!
Greetings krjw

Measure the rendered size of a SwiftUI view?

Is there a way to measure the computed size of a view after SwiftUI runs its view rendering phase? For example, given the following view:
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
}
}
With the view selected, the computed size is displayed In the preview canvas in the bottom left corner. Does anyone know of a way to get access to that size in code?
Printing out values is good, but being able to use them inside the parent view (or elsewhere) is better. So I took one more step to elaborate it.
struct GeometryGetter: View {
#Binding var rect: CGRect
var body: some View {
GeometryReader { (g) -> Path in
print("width: \(g.size.width), height: \(g.size.height)")
DispatchQueue.main.async { // avoids warning: 'Modifying state during view update.' Doesn't look very reliable, but works.
self.rect = g.frame(in: .global)
}
return Path() // could be some other dummy view
}
}
}
struct ContentView: View {
#State private var rect1: CGRect = CGRect()
var body: some View {
HStack {
// make to texts equal width, for example
// this is not a good way to achieve this, just for demo
Text("Long text").background(Color.blue).background(GeometryGetter(rect: $rect1))
// You can then use rect in other places of your view:
Text("text").frame(width: rect1.width, height: rect1.height).background(Color.green)
Text("text").background(Color.yellow)
}
}
}
You could add an "overlay" using a GeometryReader to see the values. But in practice it would probably be better to use a "background" modifier and handle the sizing value discretely
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
.overlay(
GeometryReader { proxy in
Text("\(proxy.size.width) x \(proxy.size.height)")
}
)
}
}
Here is the ugly way I came up with to achieve this:
struct GeometryPrintingView: View {
var body: some View {
GeometryReader { geometry in
return self.makeViewAndPrint(geometry: geometry)
}
}
func makeViewAndPrint(geometry: GeometryProxy) -> Text {
print(geometry.size)
return Text("")
}
}
And updated Foo version:
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
.overlay(GeometryPrintingView())
}
}
To anyone who wants to obtain a size out of Jack's solution and store it in some property for further use:
.overlay(
GeometryReader { proxy in
Text(String())
.onAppear() {
// Property, eg
// #State private var viewSizeProperty = CGSize.zero
viewSizeProperty = proxy.size
}
.opacity(.zero)
}
)
This is a bit dirty obviously, but why not if it works.
Try to use PreferenceKey like this.
struct HeightPreferenceKey : PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct WidthPreferenceKey : PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct SizePreferenceKey : PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
extension View {
func readWidth() -> some View {
background(GeometryReader {
Color.clear.preference(key: WidthPreferenceKey.self, value: $0.size.width)
})
}
func readHeight() -> some View {
background(GeometryReader {
Color.clear.preference(key: HeightPreferenceKey.self, value: $0.size.height)
})
}
func onWidthChange(perform action: #escaping (CGFloat) -> Void) -> some View {
onPreferenceChange(WidthPreferenceKey.self) { width in
action(width)
}
}
func onHeightChange(perform action: #escaping (CGFloat) -> Void) -> some View {
onPreferenceChange(HeightPreferenceKey.self) { height in
action(height)
}
}
func readSize() -> some View {
background(GeometryReader {
Color.clear.preference(key: SizePreferenceKey.self, value: $0.size)
})
}
func onSizeChange(perform action: #escaping (CGSize) -> Void) -> some View {
onPreferenceChange(SizePreferenceKey.self) { size in
action(size)
}
}
}
struct MyView: View {
#State private var height: CGFloat = 0
var body: some View {
...
.readHeight()
.onHeightChange {
height = $0
}
}
}
As others pointed out, GeometryReader and a custom PreferenceKey is the best way forward for now. I've implemented a helper drop-in library which does pretty much that: https://github.com/srgtuszy/measure-size-swiftui