Scrollview doesn't scroll past views with an offset - swiftui

To solve a much more complicated problem, I created the following simple test project:
struct ContentView: View {
var body: some View {
ScrollView {
ZStack {
ForEach(0..<50) { index in
Text("Test \(index)")
.offset(x: 0, y: CGFloat(index * 20))
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
This draws 50 Text views inside a ZStack, each one with a larger y offset, so that they are drawn down past the visible part of the screen:
The whole thing is wrapped inside of a ScrollView, so I expect that I should be able to scroll down to the last view.
However, it doesn't scroll past Test 26.
How can I arrange views inside of a ZStack by assigning offsets and update the ScrollView's contentSize?

The content size is calculated by the size of the view inside the ScrollView. So that only thing we can do is to change that view size.
By default VStack size is the total size (actual view sizes) of views inside it.
As well as we can use frame() to change the size in this case. check apple document
Since VStack arrange view in the middle we can align it to the .top.
struct ContentView: View {
var body: some View {
ScrollView() {
VStack {
ForEach(0..<50) { index in
Text("Test \(index)")
.offset(x: 0, y: CGFloat(index * 20))
}
}
.frame( height: 2000, alignment: .top) //<=here
.border(Color.black)
}
}
}

If I understood your intention correctly then you don't need offset in this scenario at all (SwiftUI works differently)
ScrollView {
VStack {
ForEach(0..<50) { index in
Text("Test \(index)") // << use .padding if needed
}
}
}
Note: The offset does not change view layout, view remains in the same place where it was created, but rendered in place of offset; frames of other views also not affected. Just be aware.

Related

Swiftui ScrollView is affected unexpectedly by scaleEffect modifier

I have some views with many details with fixed sizes, and am trying to use scaleEffect() to reduce them proportionally to fit better smaller devices. However, when using scaleEffect() on a ScrollView, I noticed that it has a larger effect than expected on the axis of the ScrollView. Small example below:
import SwiftUI
struct FancyItemView: View {
var body: some View {
Rectangle()
.fill(.red)
.frame(width: 100, height: 100)
}
}
struct ItemDisplayView: View {
var sizeAdjustment: Double
var body: some View {
ScrollView(.horizontal){
FancyItemView()
}
.background(.blue)
.scaleEffect(sizeAdjustment)
.frame(width: 150 * sizeAdjustment, height: 100 * sizeAdjustment)
.border(.black)
}
}
struct ContentView: View {
var body: some View {
VStack{
ItemDisplayView(sizeAdjustment: 1)
ItemDisplayView(sizeAdjustment: 0.8)
ItemDisplayView(sizeAdjustment: 1.2)
}
.background(.gray)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Screenshot of the resulting view: https://i.stack.imgur.com/POvjw.png
In this example I am using only one item view, but in my real code the ScrollView contains titles and grids of items. I may be able to work around this issue by applying scaleEffect to the other views around ScrollView and not applying to it, but that would make the code much more confusing. So I am wondering if there is anything I am missing to make scaleEffect work properly with ScrollView.
Thanks
I don´t think .scaleEffect is the propper tool here. It is more for visual presentation/animation than for laying out views. Get rid of the .scaleEffect modifier and pass your scale var through to your Controll and style it appropriatly.
struct FancyItemView: View {
var sizeAdjustment: Double
var body: some View {
Rectangle()
.fill(.red)
.frame(width: 100 * sizeAdjustment, height: 100 * sizeAdjustment)
}
}
struct ItemDisplayView: View {
var sizeAdjustment: Double
var body: some View {
ScrollView(.horizontal){
FancyItemView(sizeAdjustment: sizeAdjustment) // pass the multiplier to the ChildView
}
.background(.blue)
// .scaleEffect(sizeAdjustment) // remove this
// .frame(width: 150 * sizeAdjustment, height: 100 * sizeAdjustment) // you probably don´t want this either
// or at least get rid of the multiplier
.border(.black)
}
}

SwiftUI - How can I use ObservedObject or EnvironmentObject to store GeometryReader data?

I am trying to follow the design created for an app which has some objects placed in the middle of the screen.
The objects should have a size and padding proportional to the device's screen size, meaning they should appear bigger if the screen is bigger than the screen we take as a base in the design (the base is an iPhone 11 screen in this case). In addition, these objects have more objects inside, which should also be proportional to the screen size. For example: a Text view placed whithin the borders of a RoundedRectangle for which the font should grow if the screen is bigger than the screen used as a base; or an image inside another image. In these examples, the object and the objects inside of it should all be proportional to the screen size.
So far, we are using GeometryReader to accomplish this. The way we are doing it needs us to use GeometryReader in each file we have defined for a screen and its views. Once we have GeometryReader data, we use the Scale struct to get the correct proportions for the objects.
Here is the sample code:
GeometryReaderSampleView.swift
import SwiftUI
struct GeometryReaderSampleView: View {
var body: some View {
NavigationView {
GeometryReader { metrics in
ZStack {
VStack {
LoginMainDecorationView(Scale(geometry: metrics))
Spacer()
}
VStack {
HStack {
GreenSquareView(Scale(geometry: metrics))
Spacer()
}
.offset(x: 29, y: Scale(geometry: metrics).vertical(300.0))
Spacer()
}
}
}
}
}
}
struct GreenSquareView: View {
let scale:Scale
init (_ scale:Scale) {
self.scale = scale
}
var body: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: scale.horizontal(30))
.fill(Color.green)
.frame(width: scale.horizontal(157), height: scale.horizontal(146))
Text("Here goes\nsome text")
.font(.custom("TimesNewRomanPS-ItalicMT", size: scale.horizontal(20)))
.padding(.top, scale.horizontal(29))
.padding(.leading, scale.horizontal(19))
VStack {
Spacer()
HStack {
Spacer()
Image(systemName: "heart.circle")
.resizable()
.frame(width: scale.horizontal(20), height: scale.horizontal(20))
.offset(x: scale.horizontal(-20), y: scale.vertical(-17.0))
}
}.frame(width: scale.horizontal(157), height: scale.horizontal(146))
}
}
}
struct LoginMainDecorationView: View {
let scale:Scale
init (_ scale:Scale) {
self.scale = scale
}
var body: some View {
HStack {
Image(systemName: "cloud.rain")
.resizable()
.frame(width: scale.horizontal(84), height: scale.horizontal(68), alignment: .leading)
.offset(x: 0, y: scale.vertical(200.0))
Spacer()
Image(systemName: "cloud.snow")
.resizable()
.frame(width: scale.horizontal(119), height: scale.horizontal(91), alignment: .trailing)
.offset(x: scale.horizontal(-20.0), y: scale.vertical(330.0))
}
}
}
struct GeometryReaderSampleView_Previews: PreviewProvider {
static var previews: some View {
GeometryReaderSampleView()
}
}
Scale.swift
import SwiftUI
struct Scale {
// Size of iPhone 11 Pro
let originalWidth:CGFloat = 375.0
let originalHeight:CGFloat = 734.0
let horizontalProportion:CGFloat
let verticalProportion:CGFloat
init(screenWidth:CGFloat, screenHeight:CGFloat) {
horizontalProportion = screenWidth / originalWidth
verticalProportion = screenHeight / originalHeight
}
init(geometry: GeometryProxy) {
self.init(screenWidth: geometry.size.width, screenHeight: geometry.size.height)
}
func horizontal(_ value:CGFloat) -> CGFloat {
return value * horizontalProportion
}
func vertical(_ value:CGFloat) -> CGFloat {
return value * verticalProportion
}
}
The question / request
I would like to simplify this code and store the GeometryReader data (the Scale struct with its info) in an ObservedObject or an EnvironmentObject so that we can use it in different views and files all over the project. The problem with this is that we cannot get GeometryReader data until the view is loaded, and once the view is loaded I believe we cannot declare ObservedObject or EnvironmentObject anymore (is that correct?).
I know there could be a way to get the screen size without using GeometryReader as shown here: How to get the iPhone's screen width in SwiftUI?. But if I used GeometryReader to get the size of a view that is inside another view, I would like to have its information stored as well.
The goal would be not to use this code inside each view that needs to use scale:
let scale:Scale
init (_ scale:Scale) {
self.scale = scale
}
and instead use ObservedObject or EnvironmentObject to get the scale data from the views that need it. Therefore, how can I use ObservedObject or EnvironmentObject to store GeometryReader data?
I tend to think that you're fighting the general principals of SwiftUI a little by doing this (ie basing things on screen sizes rather than using the built-in SwiftUI layout principals that are screen size independent like padding). Assuming you want to go forward with the plan, though, I'd recommend using an #Envrionment value. I don't think it needs to be an #EnvironmentObject, since Scale is a struct and there's no compelling reason to have a reference-type to box the value.
Here's a simple example:
private struct ScaleKey: EnvironmentKey {
static let defaultValue = Scale(screenWidth: -1, screenHeight: -1)
}
extension EnvironmentValues {
var scale: Scale {
get { self[ScaleKey.self] }
set { self[ScaleKey.self] = newValue }
}
}
struct ContentView: View {
var body: some View {
GeometryReader { metrics in
SubView()
.environment(\.scale, Scale(geometry: metrics))
}
}
}
struct SubView : View {
#Environment(\.scale) private var scale : Scale
var body: some View {
Text("Scale: \(scale.horizontal(1)) x \(scale.vertical(1))")
}
}

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.

SwiftUI ScrollView does not center content when content fits scrollview bounds

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

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