SwiftUI ScrollView does not center content when content fits scrollview bounds - swiftui

I'm trying to have the content inside a ScrollView be centered when that content is small enough to not require scrolling, but instead it aligns to the top. Is this a bug or I'm missing adding something? Using Xcode 11.4 (11E146)
#State private var count : Int = 100
var body : some View {
// VStack {
ScrollView {
VStack {
Button(action: {
if self.count > 99 {
self.count = 5
} else {
self.count = 100
}
}) {
Text("CLICK")
}
ForEach(0...count, id: \.self) { no in
Text("entry: \(no)")
}
}
.padding(8)
.border(Color.red)
.frame(alignment: .center)
}
.border(Color.blue)
.padding(8)
// }
}

Credit goes to #Thaniel for finding the solution. My intention here is to more fully explain what is happening behind the scenes to demystify SwiftUI and explain why the solution works.
Solution
Wrap the ScrollView inside a GeometryReader so that you can set the minimum height (or width if the scroll view is horizontal) of the scrollable content to match the height of the ScrollView. This will make it so that the dimensions of the scrollable area are never smaller than the dimensions of the ScrollView. You can also declare a static dimension and use it to set the height of both the ScrollView and its content.
Dynamic Height
#State private var count : Int = 5
var body: some View {
// use GeometryReader to dynamically get the ScrollView height
GeometryReader { geometry in
ScrollView {
VStack(alignment: .leading) {
ForEach(0...self.count, id: \.self) { num in
Text("entry: \(num)")
}
}
.padding(10)
// border is drawn before the height is changed
.border(Color.red)
// match the content height with the ScrollView height and let the VStack center the content
.frame(minHeight: geometry.size.height)
}
.border(Color.blue)
}
}
Static Height
#State private var count : Int = 5
// set a static height
private let scrollViewHeight: CGFloat = 800
var body: some View {
ScrollView {
VStack(alignment: .leading) {
ForEach(0...self.count, id: \.self) { num in
Text("entry: \(num)")
}
}
.padding(10)
// border is drawn before the height is changed
.border(Color.red)
// match the content height with the ScrollView height and let the VStack center the content
.frame(minHeight: scrollViewHeight)
}
.border(Color.blue)
}
The bounds of the content appear to be smaller than the ScrollView as shown by the red border. This happens because the frame is set after the border is drawn. It also illustrates the fact that the default size of the content is smaller than the ScrollView.
Why Does it Work?
ScrollView
First, let's understand how SwiftUI's ScrollView works.
ScrollView wraps it's content in a child element called ScrollViewContentContainer.
ScrollViewContentContainer is always aligned to the top or leading edge of the ScrollView depending on whether it is scrollable along the vertical or horizontal axis or both.
ScrollViewContentContainer sizes itself according to the ScrollView content.
When the content is smaller than the ScrollView, ScrollViewContentContainer pushes it to the top or leading edge.
Center Align
Here's why the content gets centered.
The solution relies on forcing the ScrollViewContentContainer to have the same width and height as its parent ScrollView.
GeometryReader can be used to dynamically get the height of the ScrollView or a static dimension can be declared so that both the ScrollView and its content can use the same parameter to set their horizontal or vertical dimension.
Using the .frame(minWidth:,minHeight:) method on the ScrollView content ensures that it is never smaller than the ScrollView.
Using a VStack or HStack allows the content to be centered.
Because only the minimum height is set, the content can still grow larger than the ScrollView if needed, and ScrollViewContentContainer retains its default behavior of aligning to the top or leading edge.

You observe just normal ScrollView behaviour. Here is a demo of possible approach to achieve your goal.
// view pref to detect internal content height
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
// extension for modifier to detect view height
extension ViewHeightKey: ViewModifier {
func body(content: Content) -> some View {
return content.background(GeometryReader { proxy in
Color.clear.preference(key: Self.self, value: proxy.size.height)
})
}
}
// Modified your view for demo
struct TestAdjustedScrollView: View {
#State private var count : Int = 100
#State private var myHeight: CGFloat? = nil
var body : some View {
GeometryReader { gp in
ScrollView {
VStack {
Button(action: {
if self.count > 99 {
self.count = 5
} else {
self.count = 100
}
}) {
Text("CLICK")
}
ForEach(0...self.count, id: \.self) { no in
Text("entry: \(no)")
}
}
.padding(8)
.border(Color.red)
.frame(alignment: .center)
.modifier(ViewHeightKey()) // read content view height !!
}
.onPreferenceChange(ViewHeightKey.self) {
// handle content view height
self.myHeight = $0 < gp.size.height ? $0 : gp.size.height
}
.frame(height: self.myHeight) // align own height with content
.border(Color.blue)
.padding(8)
}
}
}

The frame(alignment: .center) modifier you’ve added doesn’t work since what it does is wrapping your view in a new view of exactly the same size. Because of that the alignment doesn’t do anything as there is no additional room for the view do be repositioned.
One potential solution for your problem would be to wrap the whole ScrollView in a GeometryReader to read available height. Then use that height to specify that the children should not be smaller than it. This will then make your view centered inside of ScrollView.
struct ContentView: View {
#State private var count : Int = 100
var body : some View {
GeometryReader { geometry in
ScrollView {
VStack {
Button(action: {
if self.count > 99 {
self.count = 5
} else {
self.count = 100
}
}) {
Text("CLICK")
}
ForEach(0...self.count, id: \.self) { no in
Text("entry: \(no)")
}
}
.padding(8)
.border(Color.red)
.frame(minHeight: geometry.size.height) // Here we are setting minimum height for the content
}
.border(Color.blue)
}
}
}

For me, GeometryReader aligned things to the top no matter what. I solved it with adding two extra Spacers (my code is based on this answer):
GeometryReader { metrics in
ScrollView {
VStack(spacing: 0) {
Spacer()
// your content goes here
Spacer()
}
.frame(minHeight: metrics.size.height)
}
}

Related

Swift UI ScrollView layout behaviour

Does ScrollView in SwiftUI proposes no height for its children.
ScrollView {
Color.clear
.frame(idealHeight: 200)
.background(.blue)
}
will result in
As written in frame documentation
The size proposed to this view is the size proposed to the frame, limited by any constraints specified, and with any ideal dimensions specified replacing any corresponding unspecified dimensions in the proposal.
So does it mean scrollView doesn't propose any height?
A ScrollView with a vertical axis does not offer a height because its height is potentially infinite.
So the child views adopt their idealHeight. Which in the case of a view that normally uses all the height offered, like Color, or Rectangle, corresponds to the magic number 10.
struct SwiftUIView: View {
#State private var height: CGFloat = 0
var body: some View {
ScrollView {
Rectangle()
.background(
GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
}
}
)
}
.overlay(Text(height.description), alignment: .bottom)
}
}
Note that we also find this magic number 10 (putting aside the ScrollView) if we ask the same Rectangle to use its idealHeight, using the modifier .fixedSize
struct SwiftUIView101: View {
#State private var height: CGFloat = 0
var body: some View {
VStack {
Rectangle()
.fixedSize()
.background(
GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
}
}
)
Text(height.description)
}
}
}

Size a SwiftUI view to be safeAreaInsets.top + 'x'

I am trying to create a full bleed SwiftUI 'header' view in my scene. This header will sit within a List or a scrollable VStack.
In order to do this, I'd like to have my text in the header positioned below the safe area, but the full view should extend from the top of the screen (and thus, overlap the safe area). Here is visual representation:
V:[(safe-area-spacing)-(padding)-(text)]
here is my attempt:
struct HeaderView: View {
#State var spacing: CGFloat = 100
var body: some View {
HStack {
VStack(alignment: .leading) {
Rectangle()
.frame(height: spacing)
.opacity(0.5)
Text("this!").font(.largeTitle)
Text("this!").font(.headline)
Text("that!").font(.subheadline)
}
Spacer()
}
.frame(maxWidth: .infinity)
.background(Color.red)
.background(
GeometryReader { proxy in
Color.clear
.preference(
key: SafeAreaSpacingKey.self,
value: proxy.safeAreaInsets.top
)
}
)
.onPreferenceChange(SafeAreaSpacingKey.self) { value in
self.spacing = value
}
}
}
This however, does not seem to correctly size 'Rectangle'. How can I size a view according to the safe area?
Is this what you're looking for? I try to avoid using GeometryReader unless you really need it... I created a MainView, which has a background and a foreground layer. The background layer will ignore the safe areas (full bleed) but the foreground will stay within the safe area by default.
struct HeaderView: View {
var body: some View {
HStack {
VStack(alignment: .leading) {
Text("this!").font(.largeTitle)
Text("this!").font(.headline)
Text("that!").font(.subheadline)
}
Spacer(minLength: 0)
}
}
}
struct MainView: View {
var body: some View {
ZStack {
// Background
ZStack {
}
.frame(maxWidth:. infinity, maxHeight: .infinity)
.background(Color.red)
.edgesIgnoringSafeArea(.all)
// Foreground
VStack {
HeaderView()
Spacer()
}
}
}
}
add an state to store desired height
#State desiredHeight : CGFloat = 0
then on views body :
.onAppear(perform: {
if let window = UIApplication.shared.windows.first{
let phoneSafeAreaTopnInset = window.safeAreaInsets.top
desiredHeight = phoneSafeAreaTopnInset + x
}
})
set the desiredHeight for your view .
.frame(height : desiredHeight)

SwiftUI ScrollView VStack GeometryReader height ignored

I want to use a ScrollView outside of a VStack, so that my content is scrollable if the VStack expands beyond screen size.
Now I want to use GeometryReader within the VStack and it causes problems, which I can only solve by setting the GeometryReader frame, which does not really help me given that I use the reader to define the view size.
Here is the code without a ScrollView and it works nicely:
struct MyExampleView: View {
var body: some View {
VStack {
Text("Top Label")
.background(Color.red)
GeometryReader { reader in
Text("Custom Sized Label")
.frame(width: reader.size.width, height: reader.size.width * 0.5)
.background(Color.green)
}
Text("Bottom Label")
.background(Color.blue)
}
.background(Color.yellow)
}
}
This results in the following image:
The custom sized label should be full width, but half the width for height.
Now if I wrap the same code in a ScrollView, this happens:
Not just did everything get smaller, but the height of the Custom Sized Label is somehow ignored.
If I set the height of the GeometryReader, I can adjust that behaviour, but I want to GeometryReader to grow as large as its content. How can I achieve this?
Thanks
It should be understood that GeometryReader is not a magic tool, it just reads available space in current context parent, but... ScrollView does not have own available space, it is zero, because it determines needed space from internal content... so using GeometryReader here you have got cycle - child asks parent for size, but parent expects size from child... SwiftUI renderer somehow resolves this (finding minimal known sizes), just to not crash.
Here is possible solution for your scenario - the appropriate instrument here is view preferences. Prepared & tested with Xcode 12 / iOS 14.
struct DemoLayout_Previews: PreviewProvider {
static var previews: some View {
Group {
MyExampleView()
ScrollView { MyExampleView() }
}
}
}
struct MyExampleView: View {
#State private var height = CGFloat.zero
var body: some View {
VStack {
Text("Top Label")
.background(Color.red)
Text("Custom Sized Label")
.frame(maxWidth: .infinity)
.background(GeometryReader {
// store half of current width (which is screen-wide)
// in preference
Color.clear
.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.width / 2.0)
})
.onPreferenceChange(ViewHeightKey.self) {
// read value from preference in state
self.height = $0
}
.frame(height: height) // apply from stored state
.background(Color.green)
Text("Bottom Label")
.background(Color.blue)
}
.background(Color.yellow)
}
}
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
Note: ... and don't use GeometryReader if you are not sure about context in which your view is.

How can I split VStack to two sub view with double height of second sub view than first sub view?

I just started with SwiftUI, and seems VStack and HStack is very similar as flex box in web. On the web, it's easy to split two sub views as height weight with flex
<div id="parent" style="display: flex; flex-direction: column; height: 300px">
<div id="subA" style="flex: 1; background-color: red">Subview A</div>
<div id="subB" style="flex: 2; background-color: yellow">Subview B</div>
</div>
I wonder if it's possible on swiftUI too.
VStack {
VStack {
Text("Subview A")
} // Subview A with height 100
.background(Color.red)
VStack {
Text("Subview B")
} // Subview B with height 200
.background(Color.yellow)
}
.frame(height: 300, alignment: .center)
How can I implement that?
UPDATE #2:
Thanks to this answer and code from #kontiki, here's what easily works instead of using this deprecated method:
Declare this:
#State private var rect: CGRect = CGRect()
Then create this:
struct GeometryGetter: View {
#Binding var rect: CGRect
var body: some View {
GeometryReader { geometry in
Group { () -> ShapeView<Rectangle, Color> in
DispatchQueue.main.async {
self.rect = geometry.frame(in: .global)
}
return Rectangle().fill(Color.clear)
}
}
}
}
(For those familiar with UIKit, you are basically creating an invisible CALayer or UIView in the parent and passing it's frame to the subview - apologies for not being 100% technically accurate, but remember, this is not a UIKit stack in any way.)
Now that you have the parent frame, you can use it as a base for a percentage - or "relative" - of it. In this question there's a nested VStack inside another and you want the lower Text to be twice the vertical size of the top one. In the case of this answer, adjust your `ContentView to this:
struct ContentView : View {
#State private var rect: CGRect = CGRect()
var body: some View {
VStack (spacing: 0) {
RedView().background(Color.red)
.frame(height: rect.height * 0.25)
YellowView()
}
.background(GeometryGetter(rect: $rect))
}
}
UPDATE #1:
As of beta 4, this method is deprecated. relativeHeight, relativeWidth, relativeSizehave all been deprecated. Useframeinstead. If you want *relatve* sizing based on aView's parent, use GeometryReader` instead. (See this question.)
ORIGINAL POST:
Here's what you want. Keep in mind that without modifiers, everything is centered. Also, relativeHeight seems (at least to some) not very intuitive. The key is to remember that in a VSTack the parent is 50% of the screen, so 50% of 50% is actually 25%.
Alternatively, you can dictate frame heights (letting the width take up the whole screen). but your example suggests you want the red view to be 25% of the entire screen no matter what the actual screen size is.
struct RedView: View {
var body: some View {
ZStack {
Color.red
Text("Subview A")
}
}
}
struct YellowView: View {
var body: some View {
ZStack {
Color.yellow
Text("Subview B")
}
}
}
struct ContentView : View {
var body: some View {
VStack (spacing: 0) {
RedView().background(Color.red).relativeHeight(0.50)
YellowView()
}
}
}

Square Text using aspectRatio in SwiftUI

I'm trying to achieve a following layout using Swift UI…
struct ContentView : View {
var body: some View {
List(1...5) { index in
HStack {
HStack {
Text("Item number \(index)")
Spacer()
}.padding([.leading, .top, .bottom])
.background(Color.blue)
Text("i")
.font(.title)
.italic()
.padding()
.aspectRatio(1, contentMode: .fill)
.background(Color.pink)
}.background(Color.yellow)
}
}
}
I'd like the Text("i") to be square, but setting the .aspectRatio(1, contentMode: .fill) doesn't seem to do anything…
I could set the frame width and height of the text so it's square, but it seems that setting the aspect ratio should achieve what I want in a more dynamic way.
What am I missing?
I think this is what you're looking for:
List(1..<6) { index in
HStack {
HStack {
Text("Item number \(index)")
Spacer()
}
.padding([.leading, .top, .bottom])
.background(Color.blue)
Text("i")
.font(.title)
.italic()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.aspectRatio(1, contentMode: .fill)
.background(Color.pink)
.fixedSize(horizontal: true, vertical: false)
.padding(.leading, 6)
}
.padding(6)
.background(Color.yellow)
}
The answer being said, i don't recommend giving SwiftUI too much freedom to decide the sizings. one of the biggest SwiftUI problems right now is the way it decides how to fit the views into each other. if something goes not-so-good on SwiftUI's side, it can result in too many calls to the UIKit's sizeToFit method which can slowdown the app, or even crash it.
but, if you tried this solution in a few different situations and it worked, you can assume that in your case, giving SwiftUI the choice of deciding the sizings is not problematic.
The issue is due to used different fonts for left/right sides, so paddings generate different resulting area.
Here is possible solution. The idea is to give right side rect based on default view size of left side text (this gives ability to track dynamic fonts sizes as well, automatically).
Tested with Xcode 12 / iOS 14
struct ContentView: View {
#State private var height = CGFloat.zero
var body: some View {
List(1...5, id: \.self) { index in
HStack(spacing: 8) {
HStack {
Text("Item number \(index)")
Spacer()
}
.padding([.leading, .top, .bottom])
.background(GeometryReader {
Color.blue.preference(key: ViewHeightKey.self, value: $0.frame(in: .local).size.height)
})
Text("i")
.italic()
.font(.title)
.frame(width: height, height: height)
.background(Color.pink)
}
.padding(8)
.background(Color.yellow)
.onPreferenceChange(ViewHeightKey.self) {
self.height = $0
}
}
}
}
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I managed to recreate the view in your first screenshot in SwiftUI. I wasn't sure on how much padding you wanted so I defined a private immutable variable for this value
The blue view is the one that will have the text content and could change in size so by using a GeometryReader you can get the size of the blue view and then use the height value from the size to set the width and height of the pink view. This means that whatever the height of the blue view is, the pink view will follow keeping an equal aspect ratio
The SizeGetter view below is used to get any views size using a GeometryReader and then binds that value back to a #State variable in the ContentView. Because the #State and #Binding property wrappers are being used, whenever the blueViewSize is updated SwiftUI will automatically refresh the view.
The SizeGetter view can be used for any view and is implemented using the .background() modifier as shown below
struct SizeGetter: View {
#Binding var size: CGSize;
var body: some View {
// Get the size of the view using a GeometryReader
GeometryReader { geometry in
Group { () -> AnyView in
// Get the size from the geometry
let size = geometry.frame(in: .global).size;
// If the size has changed, update the size on the main thread
// Checking if the size has changed stops an infinite layout loop
if (size != self.size) {
DispatchQueue.main.async {
self.size = size;
}
}
// Return an empty view
return AnyView(EmptyView());
}
}
}
}
struct ContentView: View {
private let padding: Length = 10;
#State private var blueViewSize: CGSize = .zero;
var body: some View {
List(1...5) { index in
// The yellow view
HStack(spacing: self.padding) {
// The blue view
HStack(spacing: 0) {
VStack(spacing: 0) {
Text("Item number \(index)")
.padding(self.padding);
}
Spacer();
}
.background(SizeGetter(size: self.$blueViewSize))
.background(Color.blue);
// The pink view
VStack(spacing: 0) {
Text("i")
.font(.title)
.italic();
}
.frame(
width: self.blueViewSize.height,
height: self.blueViewSize.height
)
.background(Color.pink);
}
.padding(self.padding)
.background(Color.yellow);
}
}
}
In my opinion it is better to set the background colour of a VStack or HStack instead of the Text view directly because you can then add more text and other views to the stack and not have to set the background colour for each one
I was searching very similar topic "Square Text in SwiftUI", came across your question and I think I've found quite simple approach to achieve your desired layout, using GeometryProxy to set width and heigh of the square view from offered geometry.size.
Checkout the code below, an example of TableCellView which can be used within List View context:
import SwiftUI
struct TableCellView: View {
var index: Int
var body: some View {
HStack {
HStack {
Text("Item number \(index)")
.padding([.top, .leading, .bottom])
Spacer()
}
.background(Color(.systemBlue))
.layoutPriority(1)
GeometryReader { geometry in
self.squareView(geometry: geometry)
}
.padding(.trailing)
}
.background(Color(.systemYellow))
.padding(.trailing)
}
func squareView(geometry: GeometryProxy) -> some View {
Text("i")
.frame(width: geometry.size.height, height: geometry.size.height)
.background(Color(.systemPink))
}
}