In a giant ScrollView, I want to place Circles, and scroll to them so that, when scrolled, they end up in the middle of the screen, one after another.
I can position the Circles either by using .position, .offset or .padding. I have positioned them (300,300) away from one-another, and so that none of them are on screen when the view is loaded.
When I scroll to the ones positioned with .position or .offset, the ScrollView scrolls to the top left. .offset scrolls with an inset, .position all the way. When I scroll to the one positioned with .padding, it is not centred.
What am I doing wrong here? Why will none of my three attempts scroll so that the circle in question is placed in the middle of the ScrollView?
struct CanvasTester: View {
#State var i = 0
var body: some View {
ZStack(alignment: .topLeading) {
ScrollViewReader { scrollView in
ScrollView([.horizontal, .vertical]) {
ZStack(alignment: .topLeading) {
Spacer()
.frame(width: 4096, height: 4096)
.background(Color.yellow)
.task {
#Sendable func f() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
withAnimation {
scrollView.scrollTo("circle\(i+1)", anchor: .center)
self.i = (self.i + 1) % 3
}
f()
}
}
f()
}
Circle()
.stroke(Color.blue, lineWidth: 4)
.frame(width: 44, height: 44)
.offset(x: 1500, y: 1500)
.id("circle1")
Circle()
.stroke(Color.green, lineWidth: 4)
.frame(width: 44, height: 44)
.position(x: 1800, y: 1800)
.id("circle2")
Circle()
.stroke(Color.red, lineWidth: 4)
.frame(width: 44, height: 44)
.padding([.top, .leading], 2100)
.id("circle3")
}
}
}
Text("#\(i+1)")
}
}
}
Quick run-through the code: I have a ScrollReader encapsulating a ScrollView that scrolls both directions and is 4096x4096. It has a yellow background that on draw launches a function that every second scrolls to the view with either ID "circle1", "circle2" or "circle3". Then follow the three circles with those labels, and finally a label I the top left corner to indicate what color number the ID has.
Related
My goal is to have a vertical paginated tabview with a scrollview inside. Scrolling as soon as you finish the scrollview you pass to the other tab and if the content of the scrollview has a lower height than the screen, scrolling passes directly to the next tab.
var body: some View {
GeometryReader { proxy in
TabView {
ForEach(colors, id: \.self) { color in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .bottom, spacing: 20) {
ForEach(0..<15) { i in
//GeometryReader { block in
VStack(alignment: .leading) {
Text("Block test test test test test test \(i)")
}
.rotationEffect(.degrees(-90))
.frame(width: 70, height: proxy.size.width, alignment: .leading)
.background(Color.green)
.id(i)
//}
//.offset(y: proxy.size.width / 2)
}
}
.frame(height: proxy.size.height)
.background(Color.blue)
}
.frame(width: proxy.size.height, height: proxy.size.width)
.background(Color.pink)
}
.frame(width: proxy.size.width,height: proxy.size.height)
}
.frame( width: proxy.size.height, height: proxy.size.width)
.rotationEffect(.degrees(90), anchor: .topLeading)
.offset(x: proxy.size.width)
.tabViewStyle(
PageTabViewStyle(indexDisplayMode: .never)
)
}
}
These are the steps I followed:
created a tabview with horizontal scrolls inside
Rotated the tabview by 90°
Rotated the Vstacks inside the scrollview by -90°
The result is exact and the scrolling of the contents is continuous passing smoothly between scroll and tab, but the only problem is that I can't control the dimensions of the Vstacks inside the scrollview and therefore I can't have Vstacks with different heights in based on the content.
I tried to add a GeometryReader { block for the VStacks but besides not giving me the correct measurements of the VStacks it breaks the layout completely.
How can I get the dimensions of each Vstack correctly?
In the code below, firstScrollProxy does not work, while secondScrollViewProxy does. I don't understand why.
The only solution I found, was to give some id to the overlay, and scroll to that. However that causes other issues for my code, and I'd rather avoid such workarounds.
I played with fixedSize() for the ZStack items, but that didn't help either.
Laying out the items vertically has the same issue, while a VStack works.
The anchor is optional, but trying different anchors does reveal the fact that the scroll view behaves as if the width of the items are the same as the entire scrollable area.
import SwiftUI
struct ContentView: View {
var body: some View {
let numItems: Int = 100
let itemWidth = 60.0
let itemHeight = 100.0
VStack(spacing: 4) {
Spacer()
ScrollViewReader { firstScrollProxy in
ScrollView(.horizontal) {
ZStack {
ForEach(0..<numItems, id:\.self) { x in
Rectangle()
.fill(Color.purple)
.frame(width: itemWidth - 2, height: itemHeight)
.overlay {
Text("\(x)")
}
.position(x: Double(x) * itemWidth + itemWidth / 2.0, y: itemHeight / 2.0)
}
}
.frame(width: Double(numItems) * itemWidth, height: itemHeight)
}
.onTapGesture {
withAnimation {
firstScrollProxy.scrollTo(17, anchor: .center)
}
}
}
.padding(8)
.background(Color(white: 0.2))
Color.clear.frame(height: 10)
ScrollViewReader { secondScrollProxy in
ScrollView(.horizontal) {
HStack(spacing: 2) {
ForEach(0..<numItems, id:\.self) { x in
Rectangle()
.fill(Color.blue)
.frame(width: itemWidth - 2, height: itemHeight)
.overlay {
Text("\(x)")
}
}
}
.frame(height: itemHeight)
}
.onTapGesture {
withAnimation {
secondScrollProxy.scrollTo(17, anchor: .center)
}
}
}
.padding(8)
.background(Color(white: 0.25))
Spacer()
}
.background(.black)
.foregroundColor(.white)
}
}
ZStack doesn't know about the effect of the position modifier. ZStack just assumes all of its children are piled up on top of each other, since that's how it lays them out. So when the ZStack's parent ScrollView asks the ZStack for its size, the ZStack reports a size that is the maximum width and height of any of its children, without accounting for the side-by-side layout you have manually implemented.
Given Rob's answer, the solution is actually to use the anchor to calculate the the position to scroll to. E.g.
ZStack {
// ...
}
.id("zstack")
.onTapGesture {
.withAnimation {
let x = position_of_17 / width_of_zstack
firstScrollProxy.scrollTo("zstack", anchor: UnitPoint(x: x, y: 1.0))
}
}
}
I found a better workaround, if anyone should have this problem. Simply embed a HStack with a width equal to the ZStack in which you can add invisible elements with the ids and the location that you need to scroll to.
In my case this is acceptable, I only ever need to programmatically scroll to 1 element, and switching to an actual HStack view for my content would not make sense for my use case.
ZStack {
// Content
HStack(spacing: 0) {
Color.clear
.frame(width: targetX, height: 1)
Color.clear
.frame(width: itemWidth, height: 1)
.id("target")
Color.clear
.frame(width: totalWidth - targetX - itemWidth, height: 1)
}
}
func scrollToTarget() {
scrollProxy.scrollTo("target", anchor: anchor)
}
When adding a shadow to my view in a Grid, the scrolling experience is bad. It feels like the Frame Rate is dropping. I came across this post, option 1 made my whole background for my view the same color as my shadow. I Don't really know how to implement UIViewRepresentable in option 2.
So how would I be able to use UIViewRepresentable, or is there a better way to do this.
MRE CODE
struct ContentView: View {
#State var gridSpacing: CGFloat = 8
let columns: [GridItem] = [GridItem(.flexible(),spacing: 8), GridItem(.flexible(),spacing: 8),GridItem(.flexible(),spacing: 8)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: gridSpacing) {
ForEach(0..<200) { x in
VStack(spacing: 8) {
Image("sumo-deadlift") //<----- Replace Image
.resizable()
.scaledToFit()
.background(.yellow)
.clipShape(Circle())
.padding(.horizontal)
.shadow(color: .blue, radius: 2, x: 1, y: 1) //<----- Comment/Uncomment
Text("Sumo Deadlift")
.font(.footnote.weight(.semibold))
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(maxWidth: .infinity)
Text("Legs")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.all)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.mask(RoundedRectangle(cornerRadius: 20))
.shadow(color: .red, radius: 2, x: 1, y: 1) //<----- Comment/Uncomment
}
}
.padding(.all, 8)
}
.background(.ultraThinMaterial)
}
}
I don't understand why I have extraneous vertical spacing between elements in a ForEach that is inside a VStack that is inside a ScrollView when using a GeometryReader to render a custom horizontal separator line.
ScrollView {
ForEach(self.model1.elements, id: \.self) { element in
VStack.init(alignment: .leading, spacing: 0) {
// Text("test") // image 3: works correctly
// .background(Color.blue)
GeometryReader { geometry in
Path { path in
path.move(to: .init(x: 0, y: 0))
path.addLine(to: .init(x: geometry.size.width, y: 0))
}
.strokedPath(.init(lineWidth: 1, dash: [1,2]))
}
// .frame(maxHeight: 1) // image 2: uncommenting this line doesn't fix the spacing
.foregroundColor(.red)
.background(Color.blue)
HStack {
Text("\(element.index+1)")
.font(.system(.caption, design: .rounded))
.frame(width: 32, alignment: .trailing)
Text(element.element)
.font(.system(.caption, design: .monospaced))
.frame(maxWidth:nil)
Spacer()
}
.frame(maxWidth:nil)
.background(Color.green)
}
.border(Color.red)
}
}
The above code produces this:
With .frame(maxHeight: 1) the blue padding is gone, but still there is white space between the consecutive HStacks.
I want the vertical spacing to be like in this image, ie 0. This image is achieved by uncommenting the Text("test") source, and commenting the GeometryReader code.
I'm using Xcode 11.3.1 (11C504)
If I understand your ultimate goal, it's to have each row bordered above by a dotted line, with no padding between them, like this:
In that case, IMO you should put the lines in a background. For example:
ScrollView {
VStack(alignment: .leading) {
ForEach(self.elements, id: \.self) { element in
HStack {
Text("\(element.index+1)")
.font(.system(.caption, design: .rounded))
.frame(width: 32, alignment: .trailing)
Text(element.element)
.font(.system(.caption, design: .monospaced))
Spacer()
}
.background(
ZStack(alignment: .top) {
Color.green
GeometryReader { geometry in
Path { path in
path.move(to: .zero)
path.addLine(to: CGPoint(x: geometry.size.width, y: 0))
}
.strokedPath(StrokeStyle(lineWidth: 1, dash: [1,2]))
.foregroundColor(Color.red)
}
}
)
}
}
}
A key point is that ScrollView is not a vertical stacking container. It just takes its content and makes it scrollable. Its content in your code is a ForEach, which generates views; it doesn't really intend to lay them out. So when you combine a ScrollView with a ForEach, nothing is really in charge of placing the views the way you want. To stack views vertically, you want a VStack.
You can apply this to your structure just as well, but adding another VStack, and get the same results:
ScrollView {
VStack(spacing: 0) { // <---
ForEach(self.elements, id: \.self) { element in
VStack(alignment: .leading, spacing: 0) {
GeometryReader { geometry in
Path { path in
path.move(to: .init(x: 0, y: 0))
path.addLine(to: .init(x: geometry.size.width, y: 0))
}
.strokedPath(.init(lineWidth: 1, dash: [1,2]))
}
.foregroundColor(.red)
.frame(height: 1)
HStack {
Text("\(element.index+1)")
.font(.system(.caption, design: .rounded))
.frame(width: 32, alignment: .trailing)
Text(element.element)
.font(.system(.caption, design: .monospaced))
.frame(maxWidth:nil)
Spacer()
}
}
.background(Color.green) // <-- Moved
}
}
}
The spacing is generated by VStack.
When creating a VStack, add the parameter for spacing and set it to 0.
VStack(spacing: 0) {
Element1()
Element2()
Element3()
Element4()
}
VStack
The same spacing property is available for HStack, LazyVStack and LazyHStack
If I put several Views in a row with no spaces (or very little space between them), and attach some Gesture action, I can't correctly detect, which one is tapped. It looks like there is some padding inside gesture that I can't remove.
here's the example code:
struct ContentView: View {
#State var tap = 0
#State var lastClick = CGPoint.zero
var body: some View {
VStack{
Text("last tap: \(tap)")
Text("coordinates: (x: \(Int(lastClick.x)), y: \(Int(lastClick.y)))")
HStack(spacing: 0){
ForEach(0...4, id: \.self){ind in
RoundedRectangle(cornerRadius: 10)
.foregroundColor(Color.gray)
.overlay(Text("\(ind)"))
.overlay(Circle()
.frame(width: 4, height: 4)
.foregroundColor(self.tap == ind ? Color.red : Color.clear)
.position(self.lastClick)
)
.frame(width: 40, height: 50)
//.border(Color.black, width: 0.5)
.gesture(DragGesture(minimumDistance: 0)
.onEnded(){value in
self.tap = ind
self.lastClick = value.startLocation
}
)
}
}
}
}
}
and behavior:
I want Gesture to detect 0 button clicked when click location goes negative. Is there a way to do that?
I spent few hours with that problem, and just after I post this question, I found solution.
it was so simple - just to add a contentShape modifier
...
.frame(width: 40, height: 50)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0)
...