Dynamic content hight in VStack SwiftUI - swift

Why I can't dynamically change content in VStack if I don't have a lot objects in ScrollView? What is surprising, if there are a lot of objects, then everything changes beautifully, but if there are few, then the scroll only works down (because of this, there may be glitches when trying to scroll up)
My code:
import Introspect
struct SwiftUIView: View {
#StateObject var myCoord = MyCoord()
var body: some View {
VStack {
Color.red
.frame(height: myCoord.height)
ScrollView {
ForEach(0..<20) { _ in
Color.green
.frame(height: 20)
}
.background(Color.red)
}
.introspectScrollView { scroll in
scroll.delegate = myCoord
}
}
}
}
class MyCoord: NSObject, UIScrollViewDelegate, ObservableObject {
let maxSize: CGFloat = 76
let minSize: CGFloat = 56
#Published var height: CGFloat = 76
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let size = maxSize - scrollView.contentOffset.y
height = min(maxSize, max(minSize, size))
}
}

ScrollView
Solved was putt all view in ZStack and add some fake views in Scroll view.
1 view is responsible for the size where the scroll cannot fall through.
2 view is responsible for the size between the maximum and minimum size of the header
Here's a examples
import Introspect
struct ContentView: View {
#StateObject private var myCoord = MyCoord()
var body: some View {
let sizeOfPlaceholder = myCoord.maxSize - myCoord.minSize
ZStack(alignment: .top) {
VStack(spacing: 0) {
Color.black
.frame(height: myCoord.minSize)
ScrollView {
Color.brown
.frame(height:sizeOfPlaceholder)
ForEach(0..<20) { number in
Text("\(number)")
.background(Color.red)
.frame(height: 20)
}
}
.padding(.vertical, 1)
.introspectScrollView { scroll in
scroll.delegate = myCoord
}
}
Color.green
.frame(height: myCoord.height)
}
}
}
class MyCoord: NSObject, UIScrollViewDelegate, ObservableObject {
let maxSize: CGFloat = 76
let minSize: CGFloat = 56
#Published var height: CGFloat = 76
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print(scrollView.contentOffset.y)
let size = maxSize - scrollView.contentOffset.y
height = min(maxSize, max(minSize, size))
}
}

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

SwiftUI/UIKit: Horizontal ScrollView to scale view

I am trying to zoom a view horizontally using UIScrollView.
My code almost gives me what I want except that it also scales the view vertically.
What I want to achieve is that the image only gets scaled in the horizontal direction and that the ScrollView takes care of the horizontal scrolling.
When I remove the .flexibleHeight option from the autoresizingMask of the hostedView the image doesn't show anymore. Why is that? How can I get my desired behaviour?
struct ContentView: View {
var body: some View {
ZoomableScrollView {
Image(systemName: "star")
.resizable()
.scaledToFit()
}
}
}
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
private var content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = 20
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
// create a UIHostingController to hold our SwiftUI content
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
scrollView.addSubview(hostedView)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content))
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
// update the hosting controller's SwiftUI content
context.coordinator.hostingController.rootView = self.content
assert(context.coordinator.hostingController.view.superview == uiView)
}
// MARK: - Coordinator
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
init(hostingController: UIHostingController<Content>) {
self.hostingController = hostingController
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
}
}
This does it in pure SwiftUI:
struct HorizontalScaleView<Content: View>: View {
#ViewBuilder var content: Content
#State private var currentAmount: CGFloat = 0
#State private var finalAmount: CGFloat = 1
var body: some View {
GeometryReader { geo in
ScrollView(.horizontal) {
content
.scaledToFit()
.scaleEffect(x: finalAmount + currentAmount, y: 1)
.frame(width: (finalAmount + currentAmount) * geo.size.width, height: geo.size.width)
.gesture(
MagnificationGesture()
.onChanged { amount in
self.currentAmount = amount - 1
}
.onEnded { _ in
if self.finalAmount + self.currentAmount >= 1 {
self.finalAmount += self.currentAmount
} else {
self.finalAmount = 1
}
self.currentAmount = 0
}
)
}
}
}
}

SwiftUI | GeometryReader: Smooth resizable Header when scrolling through List

I am new to SwiftUI and I want to recreate the Contact-Card View from the Contacts App.
I am struggling to resize the Image on the top smoothly when scrolling in the List below.
I have tried using GeometryReader, but ran into issues there.
When scrolling up for example, the picture size just jumps abruptly to the minimumPictureSize I have specified. The opposite happens when scrolling up: It stops resizing abruptly when I stop scrolling.
Wanted behaviour: https://gifyu.com/image/Ai04
Current behaviour: https://gifyu.com/image/AjIc
struct SwiftUIView: View {
#State var startOffset: CGFloat = 0
#State var offset: CGFloat = 0
var minPictureSize: CGFloat = 100
var maxPictureSize: CGFloat = 200
var body: some View {
VStack {
Image("person")
.resizable()
.frame(width: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)),
height: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)))
.mask(Circle())
Text("startOffset: \(startOffset)")
Text("offset: \(offset)")
List {
Section {
Text("Top Section")
}.overlay(
GeometryReader(){ geometry -> Color in
let rect = geometry.frame(in: .global)
if startOffset == 0 {
DispatchQueue.main.async {
startOffset = rect.minY
}
}
DispatchQueue.main.async {
offset = rect.minY - startOffset
}
return Color.clear
}
)
ForEach((0..<10)) { row in
Section {
Text("\(row)")
}
}
}.listStyle(InsetGroupedListStyle())
}.navigationBarHidden(true)
}
}
Not a perfect solution, but you could separate the header and List into 2 layers in a ZStack:
struct SwiftUIView: View {
#State var startOffset: CGFloat!
#State var offset: CGFloat = 0
let minPictureSize: CGFloat = 100
let maxPictureSize: CGFloat = 200
var body: some View {
ZStack(alignment: .top) {
if startOffset != nil {
List {
Section {
Text("Top Section")
} header: {
// Leave extra space for `List` so it won't clip its content
Color.clear.frame(height: 100)
}
.overlay {
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
let frame = geometry.frame(in: .global)
offset = frame.minY - startOffset
}
return Color.clear
}
}
ForEach((0..<10)) { row in
Section {
Text("\(row)")
}
}
}
.listStyle(InsetGroupedListStyle())
.padding(.top, startOffset-100) // Make up extra space
}
VStack {
Circle().fill(.secondary)
.frame(width: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)),
height: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)))
Text("startOffset: \(startOffset ?? -1)")
Text("offset: \(offset)")
}
.frame(maxWidth: .infinity)
.padding(.bottom, 20)
.background(Color(uiColor: UIColor.systemBackground))
.overlay {
if startOffset == nil {
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
let frame = geometry.frame(in: .global)
startOffset = frame.maxY + // Original small one
maxPictureSize - minPictureSize -
frame.minY // Top safe area height
}
return Color.clear
}
}
}
}
.navigationBarHidden(true)
}
}
Notice that Color.clear.frame(height: 100) and .padding(.top, startOffset-100) are intended to leave extra space for List to avoid being clipped, which will cause the scroll bar get clipped. Alternatively, UIScrollView.appearance().clipsToBounds = true will work. However, it'll make element which moves outside the bounds of List disappear. Don't know if it's a bug.

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!