Get the current position of ScrollView in SwiftUI? - swift

I saw the position property but I think that is just used to set x and y, but I don't know what about recognizing current location.
Or totally how to use events income properties like onHover?

See What is Geometry Reader in SwiftUI?, specifically the discussion about GeometryGetter. If you place a GeometryGetter at the top of your ScrollView contents, it will emit its frame using the binding you pass to it. The origin of this frame will be the negative content offset of the scroll view.

In the following example you see how you can use GeometryReader to get the horizontal position of the content in the scroll view. However, I did not succeed yet in finding out how to set the scroll position. (Xcode 11.0 beta 6 (11M392q))
struct TimelineView: View {
#State private var posX: CGFloat = 0
var body: some View {
GeometryReader { geo in
VStack {
Text("\(self.posX)")
ScrollView(.horizontal, showsIndicators: true) {
VStack {
GeometryReader { innerGeo -> Text in
self.posX = innerGeo.frame(in: .global).minX
return Text("")
}
TimelineGridView()
}
}
.position(x: geo.size.width / 2, y: geo.size.height / 2)
}
}
}
}
where:
struct TimelineGridView: View {
var body: some View {
VStack {
ForEach(0...10, id: \.self) { rowIndex in
TimelineRowView()
}
}
}
}
struct TimelineRowView: View {
var body: some View {
HStack {
ForEach(0...100, id: \.self) { itemIndex in
TimelineCellView()
}
}
}
}
struct TimelineCellView: View {
var body: some View {
Rectangle()
.fill(Color.yellow)
.opacity(0.5)
.frame(width: 10, height: 10, alignment: .bottomLeading)
}
}
```

You can use TrackableScrollView by #maxnatchanon
Here is the code:
//
// TrackableScrollView.swift
// TrackableScrollView
//
// Created by Frad LEE on 2020/6/21.
// Copyright © 2020 Frad LEE. All rights reserved.
//
import SwiftUI
/// A trackable and scrollable view. Read [this link](https://medium.com/#maxnatchanon/swiftui-how-to-get-content-offset-from-scrollview-5ce1f84603ec) for more.
///
/// The trackable scroll view displays its content within the trackable scrollable content region.
///
/// # Usage
///
/// ``` swift
/// struct ContentView: View {
/// #State private var scrollViewContentOffset = CGFloat(0) // Content offset available to use
///
/// var body: some View {
/// TrackableScrollView(.vertical, showIndicators: false, contentOffset: $scrollViewContentOffset) {
/// ...
/// }
/// }
/// }
/// ```
struct TrackableScrollView<Content>: View where Content: View {
let axes: Axis.Set
let showIndicators: Bool
#Binding var contentOffset: CGFloat
let content: Content
/// Creates a new instance that’s scrollable in the direction of the given axis and can show indicators while scrolling.
/// - Parameters:
/// - axes: The scrollable axes of the scroll view.
/// - showIndicators: A value that indicates whether the scroll view displays the scrollable component of the content offset, in a way that’s suitable for the platform.
/// - contentOffset: A value that indicates offset of content.
/// - content: The scroll view’s content.
init(_ axes: Axis.Set = .vertical, showIndicators: Bool = true, contentOffset: Binding<CGFloat>, #ViewBuilder content: () -> Content) {
self.axes = axes
self.showIndicators = showIndicators
_contentOffset = contentOffset
self.content = content()
}
var body: some View {
GeometryReader { outsideProxy in
ScrollView(self.axes, showsIndicators: self.showIndicators) {
ZStack(alignment: self.axes == .vertical ? .top : .leading) {
GeometryReader { insideProxy in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: [self.calculateContentOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy)])
}
VStack {
self.content
}
}
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
self.contentOffset = value[0]
}
}
}
private func calculateContentOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
if axes == .vertical {
return outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY
} else {
return outsideProxy.frame(in: .global).minX - insideProxy.frame(in: .global).minX
}
}
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
typealias Value = [CGFloat]
static var defaultValue: [CGFloat] = [0]
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}

If you don't find any option. You can still use the standard UIScrollView with their delegates with UIViewRepresentable by making a separate struct an conforming to it.
More Detail on that you can find on the SwiftUI Tutorials:
https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit

I created SwiftPackage for this purpose(Pure SwiftUI 👍)
With this lib, you can get position of ScrollView.
https://github.com/kazuooooo/PositionScrollView
Medium post
import Foundation
import SwiftUI
/// Extended ScrollView which can controll position
public struct MinimalHorizontalExample: View, PositionScrollViewDelegate {
/// Page size of Scroll
var pageSize = CGSize(width: 200, height: 300)
// Create PositionScrollViewModel
// (Need to create in parent view to bind the state between this view and PositionScrollView)
#ObservedObject var psViewModel = PositionScrollViewModel(
pageSize: CGSize(width: 200, height: 300),
horizontalScroll: Scroll(
scrollSetting: ScrollSetting(pageCount: 5, afterMoveType: .stickNearestUnitEdge),
pageLength: 200 // Page length of direction
)
)
public var body: some View {
return VStack {
PositionScrollView(
viewModel: self.psViewModel,
delegate: self
) {
HStack(spacing: 0) {
ForEach(0...4, id: \.self){ i in
ZStack {
Rectangle()
.fill(BLUES[i])
.border(Color.black)
.frame(
width: self.pageSize.width, height: self.pageSize.height
)
Text("Page\(i)")
.foregroundColor(Color.white)
.font(.system(size: 24, weight: .heavy, design: .default))
}
}
}
}
// Get page via scroll object
Text("page: \(self.psViewModel.horizontalScroll?.page ?? 0)")
// Get position via scroll object
Text("position: \(self.psViewModel.horizontalScroll?.position ?? 0)")
}
}
struct SampleView_Previews: PreviewProvider {
static var previews: some View {
return MinimalHorizontalExample()
}
}
// Delegate methods of PositionScrollView
// You can monitor changes of position
public func onScrollStart() {
print("onScrollStart")
}
public func onChangePage(page: Int) {
print("onChangePage to page: \(page)")
}
public func onChangePosition(position: CGFloat) {
print("position: \(position)")
}
public func onScrollEnd() {
print("onScrollEnd")
}
}

Related

SwiftUI - Dynamic LazyHGrid row height

I'm creating vertical layout which has scrollable horizontal LazyHGrid in it. The problem is that views in LazyHGrid can have different heights (primarly because of dynamic text lines) but the grid always calculates height of itself based on first element in grid:
What I want is changing size of that light red rectangle based on visible items, so when there are smaller items visible it should look like this:
and when there are bigger items it should look like this:
This is code which results in state on the first image:
struct TestView: PreviewProvider {
static var previews: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem()],
alignment: .top,
spacing: 16
) {
Color.red
.frame(width: 64, height: 24)
ForEach(Array(0...10), id: \.self) { value in
Color.red
.frame(width: 64, height: CGFloat.random(in: 32...92))
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}
}
}
Something similar what I want can be achieved by this:
extension View {
func readSize(edgesIgnoringSafeArea: Edge.Set = [], onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
SwiftUI.Color.clear
.preference(key: ReadSizePreferenceKey.self, value: geometryProxy.size)
}.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
)
.onPreferenceChange(ReadSizePreferenceKey.self) { size in
DispatchQueue.main.async { onChange(size) }
}
}
}
struct ReadSizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
struct Size: Equatable {
var height: CGFloat
var isValid: Bool
}
struct TestView: View {
#State private var sizes = [Int: Size]()
#State private var height: CGFloat = 32
static let values: [(Int, CGFloat)] =
(0...3).map { ($0, CGFloat(32)) }
+ (4...10).map { ($0, CGFloat(92)) }
var body: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem(.fixed(height))],
alignment: .top,
spacing: 16
) {
ForEach(Array(Self.values), id: \.0) { value in
Color.red
.frame(width: 300, height: value.1)
.readSize { sizes[value.0]?.height = $0.height }
.onAppear {
if sizes[value.0] == nil {
sizes[value.0] = Size(height: .zero, isValid: true)
} else {
sizes[value.0]?.isValid = true
}
}
.onDisappear { sizes[value.0]?.isValid = false }
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}.onChange(of: sizes) { sizes in
height = sizes.filter { $0.1.isValid }.map { $0.1.height }.max() ?? 32
}
}
}
... but as you see its kind of laggy and a little bit complicated, isn't there better solution? Thank you everyone!
The height of a row in a LazyHGrid is driven by the height of the tallest cell. According to the example you provided, the data source will only show a smaller height if it has only a small size at the beginning.
Unless the first rendering will know that there are different heights, use the larger value as the height.
Is your expected UI behaviour that the height will automatically switch? Or use the highest height from the start.

How can I make a Custom ScrollView without using SwiftUI ScrollView?

I'm currently working on Custom ScrollView without ScrollView.
I don't want to add too many features, but I want to be able to scroll by dragging.
I've searched the Internet, but all I can find are examples of wrapped ScrollView.
The code I'm working on is as follows(WIP code):
import SwiftUI
struct ContentView: View {
#State private var yOffset: CGFloat = 0
#State private var contentSize: CGSize = .zero
var body: some View {
CustomScrollView {
ForEach(0..<100) { i in
Text(
"\(i)"
)
.frame(
maxWidth: .infinity
)
.background(
Color.green
)
}
.offset(y: 0)
.size(size: $contentSize)
}
.offset(y: 0)
}
}
struct CustomScrollView<Content: View>: View {
let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
self.content
}
}
//==================================================
// For extension
//==================================================
struct ChildSizeReader<Content: View>: View {
#Binding var size: CGSize
let content: () -> Content
var body: some View {
// Remove ZStack from the existing answer.
content().background(
GeometryReader { proxy in
Color.clear.preference(
key: SizePreferenceKey.self,
value: proxy.size
)
}
)
.onPreferenceChange(SizePreferenceKey.self) { preferences in
self.size = preferences
}
}
}
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: Value = .zero
static func reduce(value _: inout Value, nextValue: () -> Value) {
_ = nextValue()
}
}
extension View {
func size(size: Binding<CGSize>) -> some View {
ChildSizeReader(size: size) {
self
}
}
}
As you can see in the attached image, the top of the content is missing.
I would like to adjust it so that the top is visible, but I don't know how to do that.
Note:
The dragging and other processes will be implemented after the top is visible first.
Reference:
https://www.youtube.com/watch?v=vuFUX1Qwrmo
SwiftUI - Get size of child?

Creating a drag handle inside of a SwiftUI view (for draggable windows and such)

I'm trying to learn SwiftUI, and I had a question about how to make a component that has a handle on it which you can use to drag around. There are several tutorials online about how to make a draggable component, but none of them exactly answer the question I have, so I thought I would seek the wisdom of you fine people.
Lets say you have a view that's like a window with a title bar. For simplicity's sake, lets make it like this:
struct WindowView: View {
var body: some View {
VStack(spacing:0) {
Color.red
.frame(height:25)
Color.blue
}
}
}
I.e. the red part at the top is the title bar, and the main body of the component is the blue area. Now, this window view is contained inside another view, and you can drag it around. The way I've read it, you should do something like this (very simplified):
struct ContainerView: View {
#State private var loc = CGPoint(x:150, y:150);
var body: some View {
ZStack {
WindowView()
.frame(width:100, height:100)
.position(loc)
.gesture(DragGesture()
.onChanged { value in
loc = value.location
}
)
}
}
}
and that indeed lets you drag the component around (ignore for now that we're always dragging by the center of the image, it's not really the point):
However, this is not what I want: I don't want you to be able to drag the component around by just dragging inside the window, I only want to drag it around by dragging the red title bar. But the red title-bar is hidden somewhere inside of WindowView. I don't want to move the #State variable containing the position to inside the WindowView, it seems to me much more logical to have that inside ContainerView. But then I need to somehow forward the gesture into the embedded title bar.
I imagine the best way would be for the ContainerView to look something like this:
struct ContainerView: View {
#State private var loc = CGPoint(x:150, y:150);
var body: some View {
ZStack {
WindowView()
.frame(width:100, height:100)
.position(loc)
.titleBarGesture(DragGesture()
.onChanged { value in
loc = value.location
}
)
}
}
}
but I don't know how you would implement that .titleBarGesture in the correct way (or if this is even the proper way to do it. should the gesture be an argument to the WindowView constructor?). Can anyone help me out, give me some pointers?
Thanks in advance!
You can get smooth translation of the window using the offset from the drag, and then disable touch on the background element to prevent content from dragging.
Buttons still work in the content area.
import SwiftUI
struct WindowBar: View {
#Binding var location: CGPoint
var body: some View {
ZStack {
Color.red
.frame(height:25)
Text(String(format: "%.1f: %.1f", location.x, location.y))
}
}
}
struct WindowContent: View {
var body: some View {
ZStack {
Color.blue
.allowsHitTesting(false) // background prevents interaction
Button("Press Me") {
print("Tap")
}
}
}
}
struct WindowView: View, Identifiable {
#State var location: CGPoint // The views current center position
let id = UUID()
/// Keep track of total translation so that we don't jump on finger drag
/// SwiftUI doesn't have an onBegin callback like UIKit's gestures
#State private var startDragLocation = CGPoint.zero
#State private var isBeginDrag = true
init(location: CGPoint = .zero) {
_location = .init(initialValue: location)
}
var body: some View {
VStack(spacing:0) {
WindowBar(location: $location)
WindowContent()
}
.frame(width: 100, height: 100)
.position(location)
.gesture(DragGesture()
.onChanged({ value in
if isBeginDrag {
isBeginDrag = false
startDragLocation = location
}
// In UIKit we can reset translation to zero, but we can't in SwiftUI
// So we do book keeping to track startLocation of gesture and adjust by
// total translation
location = CGPoint(x: startDragLocation.x + value.translation.width,
y: startDragLocation.y + value.translation.height)
})
.onEnded({ value in
isBeginDrag = true /// reset for next drag
}))
}
}
struct ContainerView: View {
#State private var windows = [
WindowView(location: CGPoint(x: 50, y: 100)),
WindowView(location: CGPoint(x: 190, y: 75)),
WindowView(location: CGPoint(x: 250, y: 50))
]
var body: some View {
ZStack {
ForEach(windows) { window in
window
}
}
.frame(width: 600, height: 480)
}
}
struct ContentView: View {
var body: some View {
ContainerView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You can just use .allowsHitTesting(false) on the blue view, which will ignore the touch gesture on that view. Hence, you can only drag on the red View and still have the DragGesture outside that view.
struct WindowView: View {
var body: some View {
VStack(spacing:0) {
Color.red
.frame(height:25)
Color.blue
.allowsHitTesting(false)
}
}
}
You can wrap the DragGesture into a ViewModifier:
struct MovableByBar: ViewModifier {
static let barHeight: CGFloat = 14
#State private var loc: CGPoint!
#State private var transition: CGSize = .zero
func body(content: Content) -> some View {
if loc == nil { // Get the original position
content.padding(.top, MovableByBar.barHeight)
.overlay {
GeometryReader { geo -> Color in
DispatchQueue.main.async {
let frame = geo.frame(in: .local)
loc = CGPoint(x: frame.midX, y: frame.midY)
}
return Color.clear
}
}
} else {
VStack(spacing: 0) {
Rectangle()
.fill(.secondary)
.frame(height: MovableByBar.barHeight)
.offset(x: transition.width, y: transition.height)
.gesture (
DragGesture()
.onChanged { value in
transition = value.translation
}
.onEnded { value in
loc.x += transition.width
loc.y += transition.height
transition = .zero
}
)
content
.offset(x: transition.width,
y: transition.height)
}
.position(loc)
}
}
}
And use modifier like this:
WindowView()
.modifier(MovableByBar())
.frame(width: 80, height: 60) // `frame()` after it

Switching a #State property to a #Binding property interferes with animation

I am currently building a bottom sheet UI like Apple Map's and I encountered myself with some weird bug that I am struggling to fix.
The following snippet represents the bottom sheet and includes a ready-to-go preview. You can try it out and see that everything works as expected in both the simulator and in XCode's preview engine.
import SwiftUI
import MapKit
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#State private var opened = false
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#State private var opened = false
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}
Now we switch the opened property to be a Binding property so that the parent view can know the state of the bottom sheet.
Here is the code with the changes.
import SwiftUI
import MapKit
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
// Binding property that shares the state of the sheet to the parent view
#Binding private var opened: Bool
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
opened: Binding<Bool>,
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self._opened = opened
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#State private var opened = false
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
opened: self.$opened,
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}
}
However, if you run the preview in XCode you'll see that when the drag gesture stops, instead of the sheet snapping back to the "opened" state from where we lift the finger off, it does the animation from its initial position.
Weirdly enough, if you try to run this in an iPhone or in the simulator, it behaves as expected, as in the first example. Although I've tried to use the code in a more "complex" app (it has a TabView and some few more things) and the same bug appears.
Any ideas why this is happening?
Here is an example with the example view used in a real app tested in a real iPhone. You'll see that the bug is very noticeable.
Video: https://imgur.com/a/RFZbqbD
Actually I see here some misunderstanding that must be clarified and, probably, then initial solution should be rethink. Binding is not state shared to parent, it is link to parent's state holding source of truth, so your view becomes dependent on parent's capability to refresh it on state change, which is not always reliable (or stable, or persistent, etc.), especially in different view hierarchies (like, sheets, UIKit backend, etc.). Changing binding you do not refresh your view directly (as opposite to changes own state) even if your view depends on value in binding, but change parent state, which might do or might do not update your view back. Finalizing - what you've implied is not reliable approach by nature, and you actually observe this.
Alternate solution: use ObsevableObject/ObservedObject view model pattern.
Tested with Xcode 12.4 / iOS 14.4
import MapKit
class ReleaseGestureVM: ObservableObject {
#Published var opened: Bool = false
}
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
#ObservedObject var vm: ReleaseGestureVM
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.vm.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.vm.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
vm: ReleaseGestureVM,
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self.vm = vm
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#StateObject private var vm = ReleaseGestureVM()
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
vm: self.vm,
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}

Get width of a view using in SwiftUI

I need to get width of a rendered view in SwiftUI, which is apparently not that easy.
The way I see it is that I need a function that returns a view's dimensions, simple as that.
var body: some View {
VStack(alignment: .leading) {
Text(timer.name)
.font(.largeTitle)
.fontWeight(.heavy)
Text(timer.time)
.font(.largeTitle)
.fontWeight(.heavy)
.opacity(0.5)
}
}
The only way to get the dimensions of a View is by using a GeometryReader. The reader returns the dimensions of the container.
What is a geometry reader? the documentation says:
A container view that defines its content as a function of its own size and coordinate space. Apple Doc
So you could get the dimensions by doing this:
struct ContentView: View {
#State var frame: CGSize = .zero
var body: some View {
HStack {
GeometryReader { (geometry) in
self.makeView(geometry)
}
}
}
func makeView(_ geometry: GeometryProxy) -> some View {
print(geometry.size.width, geometry.size.height)
DispatchQueue.main.async { self.frame = geometry.size }
return Text("Test")
.frame(width: geometry.size.width)
}
}
The printed size is the dimension of the HStack that is the container of inner view.
You could potentially using another GeometryReader to get the inner dimension.
But remember, SwiftUI is a declarative framework. So you should avoid calculating dimensions for the view:
read this to more example:
Make a VStack fill the width of the screen in SwiftUI
How to make view the size of another view in SwiftUI
Getting the dimensions of a child view is the first part of the task. Bubbling the value of dimensions up is the second part. GeometryReader gets the dims of the parent view which is probably not what you want. To get the dims of the child view in question we might call a modifier on its child view which has actual size such as .background() or .overlay()
struct GeometryGetterMod: ViewModifier {
#Binding var rect: CGRect
func body(content: Content) -> some View {
print(content)
return GeometryReader { (g) -> Color in // (g) -> Content in - is what it could be, but it doesn't work
DispatchQueue.main.async { // to avoid warning
self.rect = g.frame(in: .global)
}
return Color.clear // return content - doesn't work
}
}
}
struct ContentView: View {
#State private var rect1 = CGRect()
var body: some View {
let t = HStack {
// make two texts equal width, for example
// this is not a good way to achieve this, just for demo
Text("Long text").overlay(Color.clear.modifier(GeometryGetterMod(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)
}
print(rect1)
return t
}
}
Here is another convenient way to get and do something with the size of current view: readSize function.
extension View {
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
Usage:
struct ContentView: View {
#State private var commonSize = CGSize()
var body: some View {
VStack {
Text("Hello, world!")
.padding()
.border(.yellow, width: 1)
.readSize { textSize in
commonSize = textSize
}
Rectangle()
.foregroundColor(.yellow)
.frame(width: commonSize.width, height: commonSize.height)
}
}
}
There's a much simpler way to get the width of a view using GeometryReader. You need to create a state variable to store the width, then surround the desired view with a GeometryReader, and set the width value to the geometry inside that width. For instace:
struct ContentView: View {
#State var width: CGFloat = 0.00 // this variable stores the width we want to get
var body: some View {
VStack(alignment: .leading) {
GeometryReader { geometry in
Text(timer.name)
.font(.largeTitle)
.fontWeight(.heavy)
.onAppear {
self.width = geometry.size.width
print("text width: \(width)") // test
}
} // in this case, we are reading the width of text
Text(timer.time)
.font(.largeTitle)
.fontWeight(.heavy)
.opacity(0.5)
}
}
}
Note that the width will change if the target's view also changes. If you want to store it, I would suggest using a let constant somewhere else. Hope that helps!