Make sheet the exact size of the content inside - swift

Let's say I have a custom view inside of a sheet, something like this
VStack {
Text("Title")
Text("Some very long text ...")
}
.padding()
.presentationDetents([.height(250)])
How can I get the exact height of the VStack and pass it to the presentationDetents modifier so that the height of the sheet is exactly the height of the content inside?

You can use a GeometryReader and PreferenceKey to read the size and then write it to a state variable. In my example, I store the entire size, but you could adjust it to store just the height, since it's likely that that is the only parameter you need.
struct ContentView: View {
#State private var showSheet = false
#State private var size: CGSize = .zero
var body: some View {
Button("View sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
VStack {
Text("Title")
Text("Some very long text ...")
}
.padding()
.background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self) { newSize in
size.height = newSize.height
}
.presentationDetents([.height(size.height)])
}
}
}
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() }
}

Using the general idea made by #jnpdx including some updates such as reading the size of the overlay instead of the background, here is what works for me:
struct ContentView: View {
#State private var showSheet = false
#State private var sheetHeight: CGFloat = .zero
var body: some View {
Button("Open sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
VStack {
Text("Title")
Text("Some very long text ...")
}
.padding()
.overlay {
GeometryReader { geometry in
Color.clear.preference(key: InnerHeightPreferenceKey.self, value: geometry.size.height)
}
}
.onPreferenceChange(InnerHeightPreferenceKey.self) { newHeight in
sheetHeight = newHeight
}
.presentationDetents([.height(sheetHeight)])
}
}
}
struct InnerHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}

More reuseable
struct InnerHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
}
extension View {
func fixedInnerHeight(_ sheetHeight: Binding<CGFloat>) -> some View {
padding()
.background {
GeometryReader { proxy in
Color.clear.preference(key: InnerHeightPreferenceKey.self, value: proxy.size.height)
}
}
.onPreferenceChange(InnerHeightPreferenceKey.self) { newHeight in sheetHeight.wrappedValue = newHeight }
.presentationDetents([.height(sheetHeight.wrappedValue)])
}
}
struct ExampleView: View {
#State private var showSheet = false
#State private var sheetHeight: CGFloat = .zero
var body: some View {
Button("Open sheet") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
VStack {
Text("Title")
Text("Some very long text ...")
}
.fixedInnerHeight($sheetHeight)
}
}
}

struct ContentView: View {
#State private var showingSheet = false
let heights = stride(from: 0.1, through: 1.0, by: 0.1).map { PresentationDetent.fraction($0) }
var body: some View {
Button("Show Sheet") {
showingSheet.toggle()
}
.sheet(isPresented: $showingSheet) {
Text("Random text ")
.presentationDetents(Set(heights))
}
}
}

Related

Manipulation a private value of child view without accessing it directly

I was looking a light approach to manipulation of a private value in child view without accessing that value directly or involving me with notification or ObservableObject, I came a cross to this approach, I am okay with the result, but I need to know any improvement or better approach.
struct ContentView: View {
#State private var reset: Bool = Bool()
var body: some View {
VStack {
CircleView(reset: reset)
Button("reset") { reset.toggle() }
}
}
}
struct CircleView: View {
let reset: Bool
#State private var size: CGFloat = 100.0
var body: some View {
Circle()
.frame(width: size, height: size)
.onTapGesture { size += 50.0 }
.onChange(of: reset, perform: { _ in size = 100.0 })
}
}
Update:
struct CircleView: View {
#Binding var reset: Bool
#State private var size: CGFloat = 100.0
var body: some View {
Circle()
.frame(width: size, height: size)
.onTapGesture { size += 50.0 }
.onChange(of: reset, perform: { newValue in
if (newValue) { size = 100.0; reset.toggle() }
})
}
}

How to implement scrolling bottom down when we got to the beginning of the content, inside the scrollview using pure SwiftUI?

Here is my attempt to implement this functionality, I also tried to solve it through UIKit, it worked, but I ran into problems with dynamically changing the content of SwiftUI, which was inside UIScrollView. More precisely, the problem was in changing the height of the container
https://imgur.com/a/6du73pt
import SwiftUI
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct ContentView: View {
#State private var offset: CGFloat = 300
var body: some View {
ZStack {
Color.yellow.ignoresSafeArea()
ScrollView(.vertical) {
ForEach(0..<100, id: \.self) { _ in
Color.red
.frame(width: 250, height: 125, alignment: .center)
}
.overlay(
GeometryReader { proxy in
let offset = proxy.frame(in: .named("scroll")).minY
Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self, value: offset)
.frame(width: 0, height: 0, alignment: .center)
})
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
if value >= 0 {
offset = value + 300
}
}
.gesture(DragGesture()
.onChanged({ value in
print("scrooll")
print(value)
})
)
}
.offset(y: offset)
.gesture(DragGesture(minimumDistance: 25, coordinateSpace: .local)
.onChanged({ value in
offset = value.translation.height + 300
}))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Below is an example of how you can lock the ScrollView when you are at the top, and then allow the DragGesture to operate instead of scroll. I removed your PreferenceKey as it was not necessary. I also used frame reader to determine where in the scroll view the top cell was. Code is extensively commented.
struct ScrollViewWithPulldown: View {
#State private var offset: CGFloat = 300
#State private var scrollEnabled = true
#State private var cellRect: CGRect = .zero
// if the top of the cell is in view, origin.y will be greater than or equal to zero
var topInView: Bool {
cellRect.origin.y >= 0
}
var body: some View {
ZStack {
Color.yellow.ignoresSafeArea()
ScrollView {
ForEach(0..<100, id: \.self) { id in
Color.red
.id(id)
.frame(width: 250, height: 125, alignment: .center)
// This is inspired by https://www.fivestars.blog/articles/swiftui-share-layout-information/
.copyFrame(in: .named("scroll"), to: $cellRect)
.onChange(of: cellRect) { _ in
if id == 0 { // insure the first view however you need to
if topInView {
scrollEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollEnabled = true
}
} else {
scrollEnabled = true
}
}
}
}
}
.disabled(!scrollEnabled)
.coordinateSpace(name: "scroll")
}
.offset(y: offset)
.gesture(DragGesture()
.onChanged({ value in
// Scrolling down
if value.translation.height > 0 && topInView {
scrollEnabled = false
print("scroll locked")
print(value)
} else { // Scrolling up
scrollEnabled = true
print("scroll up")
print(value)
}
})
.onEnded({ _ in
scrollEnabled = true
})
)
}
}
A view extension inspired by FiveStar Blog:
extension View {
func readFrame(in space: CoordinateSpace, onChange: #escaping (CGRect) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: FrameInPreferenceKey.self, value: geometryProxy.frame(in: space))
}
)
.onPreferenceChange(FrameInPreferenceKey.self, perform: onChange)
}
func copyFrame(in space: CoordinateSpace, to binding: Binding<CGRect>) -> some View {
self.readFrame(in: space) { frame in
binding.wrappedValue = frame
}
}
}

PreferenceKey not working when embedded in HStack - SwiftUI

The following code is a simplified version of the issue.
The Dimensions view takes any combination of views and shows the width & height of all the views combined using PreferenceKeys.
As seen in Example 1: When constructing all the views inside Dimensions, it will work and show the sizes.
While in Examples 2 & 3: If the views are constructed outside of Dimensions, it will not work.
QUESTION: How would it work when the views are constructed outside? Thanks.
Running on Xcode Version 12.5.1 (12E507) target iOS 14.5
Here's the code:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
// Example 1
Dimensions {
HStack {
Text("Hello, world!")
.padding()
// HelloWorldVariable // <===== THIS WILL BREAK IT
// HelloWorldView() // <===== THIS WILL BREAK IT
}
}.background(Color.blue)
// Example 2
Dimensions {
HStack {
HelloWorldVariable // <===== THIS DOES NOT WORK in the HStack
}
}.background(Color.green)
// Example 3
Dimensions {
HStack {
HelloWorldView() // <===== THIS DOES NOT WORK in the HStack
}
}.background(Color.yellow)
}
}
private var HelloWorldVariable: some View {
Text("Hello, world!")
.padding()
}
}
// MARK:- HelloWorldView
struct HelloWorldView: View {
var body: some View {
Text("Hello, world!")
.padding()
}
}
// MARK:- Dimensions
struct Dimensions<Content: View>: View {
private var content: () -> Content
#State private var contentSize: CGSize = .zero
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
var body: some View {
VStack {
content()
.background(ObserveViewDimensions())
VStack {
Text("contentWidth \(contentSize.width)")
Text("contentHeight \(contentSize.height)")
}
}
.onPreferenceChange(DimensionsKey.self, perform: { value in
self.contentSize = value
})
}
}
// MARK:- ObserveViewDimensions
struct ObserveViewDimensions: View {
var body: some View {
GeometryReader { geometry in
Color.clear.preference(key: DimensionsKey.self, value: geometry.size)
}
}
}
// MARK:- DimensionsKey
struct DimensionsKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
typealias Value = CGSize
}
// MARK:- Preview
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
After further investigation, it turns out the Canvas (SwiftUI Previews) has that bug in Xcode Version 12.5.1 (12E507).
The original code works on an iPhone X running iOS 14.8
There is a workaround to make it work for the Canvas, by changing the PreferenceKey to hold an array as seen in the code below.
Inspired by:
https://swiftui-lab.com/communicating-with-the-view-tree-part-1/
Here's the new code:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
// Example 1
Dimensions {
HStack {
Text("Hello, world!")
.padding()
HelloWorldVariable
HelloWorldView()
}
}
}
}
private var HelloWorldVariable: some View {
Text("Hello, world!")
.padding()
}
}
// MARK:- HelloWorldView
struct HelloWorldView: View {
var body: some View {
Text("Hello, world!")
.padding()
}
}
// MARK:- Dimensions
struct Dimensions<Content: View>: View {
private var content: () -> Content
#State private var contentSize: CGSize = .zero
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
var body: some View {
VStack {
content()
.background(ObserveViewDimensions())
VStack {
Text("contentWidth \(contentSize.width)")
Text("contentHeight \(contentSize.height)")
}
}
.onPreferenceChange(DimensionsKey.self, perform: { value in
// 4. Add this
DispatchQueue.main.async {
self.contentSize = value.first?.size ?? .zero
}
})
}
}
// MARK:- ObserveViewDimensions 3. // <== update this with the new data type
struct ObserveViewDimensions: View {
var body: some View {
GeometryReader { geometry in
Color.clear.preference(key: DimensionsKey.self, value: [ViewSizeData(size: geometry.size)])
}
}
}
// MARK:- DimensionsKey 2. // <== update this with the new data type
struct DimensionsKey: PreferenceKey {
static var defaultValue: [ViewSizeData] = []
static func reduce(value: inout [ViewSizeData], nextValue: () -> [ViewSizeData]) {
value.append(contentsOf: nextValue())
}
typealias Value = [ViewSizeData]
}
// MARK:- ViewSizeData 1 <==== Add This
struct ViewSizeData: Identifiable, Equatable, Hashable {
let id: UUID = UUID()
let size: CGSize
static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
// MARK:- Preview
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

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

Is it possible to pass the values of GeometryReader to a #Observableobject

I need to do calculations based on the size of the device and the width of a screen.
struct TranslatorView: View {
#ObservedObject var settings = TranslationViewModel(spacing: 4, charSize: 20)
var body: some View {
GeometryReader { geometry in
VStack{
TextField("Enter your name", text:self.$settings.translateString)
}
}
}
}
My ObservableObject can be seen below
class TranslationViewModel: ObservableObject {
#Published var translateString = ""
var ScreenSize : CGFloat = 0
var spacing : CGFloat = 4
var charSize : CGFloat = 20
init(spacing: CGFloat, charSize : CGFloat) {
self.spacing = spacing
self.charSize = charSize
}
}
I need a way to pass the geometry.size.width to my ScreenSize property but have no idea how to do this.
The simplest way is to have setter-method inside the ObservableObject which returns an EmptyView.
import SwiftUI
struct TranslatorView: View {
#ObservedObject var settings = TranslationViewModel(spacing: 4, charSize: 20)
var body: some View {
GeometryReader { geometry in
VStack{
self.settings.passWidth(geometry: geometry)
TextField("Enter your name", text:self.$settings.translateString)
}
}
}
}
class TranslationViewModel: ObservableObject {
#Published var translateString = ""
var ScreenSize : CGFloat = 0
var spacing : CGFloat = 4
var charSize : CGFloat = 20
init(spacing: CGFloat, charSize : CGFloat) {
self.spacing = spacing
self.charSize = charSize
}
func passWidth(geometry: GeometryProxy) -> EmptyView {
self.ScreenSize = geometry.size.width
return EmptyView()
}
}
Then you could implement a wrapper around GeometryReader taking content: () -> Content and a closure which gets executed every time the GeometryReader gets rerendered where you can update whatever you wish.
import SwiftUI
struct TranslatorView: View {
#ObservedObject var settings = TranslationViewModel(spacing: 4, charSize: 20)
var body: some View {
GeometryReaderEasy(callback: {
self.settings.ScreenSize = $0.size.width
}) { geometry in
TextField("Enter your name", text:self.$settings.translateString)
}
}
}
struct GeometryReaderEasy<Content: View>: View {
var callback: (GeometryProxy) -> ()
var content: (GeometryProxy) -> (Content)
private func setGeometry(geometry: GeometryProxy) -> EmptyView {
callback(geometry)
return EmptyView()
}
var body: some View {
GeometryReader { geometry in
VStack{
self.setGeometry(geometry: geometry)
self.content(geometry)
}
}
}
}
You can use a simple extension on View to allow arbitrary code execution when building your views.
extension View {
func execute(_ closure: () -> Void) -> Self {
closure()
return self
}
}
And then
var body: some View {
GeometryReader { proxy
Color.clear.execute {
self.myObject.useProxy(proxy)
}
}
}
Another option is to set the value using .onAppear
struct TranslatorView: View {
#ObservedObject var settings = TranslationViewModel(spacing: 4, charSize: 20)
var body: some View {
GeometryReader { geometry in
VStack{
TextField("Enter your name", text:self.$settings.translateString)
} .onAppear {
settings.ScreenSize = geometry.size.width
}
}
}
}