I need to programmatically animate the scroll of a scrollview. The scrollview contains either an HStack or a VStack. Code I tested with is this:
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: spacing) {
ForEach(cids, id: \.self) { cid in
....
}
}
.onAppear {
withAnimation(Animation.easeInOut(duration: 4).delay(3)) {
proxy.scrollTo(testCid)
}
}
}
.frame(maxWidth: w, maxHeight: w / 2)
}
The scrollview does land on the item with the testCid, however, it does not animate.
As soon as the view comes on screen the scrollview is already on testCid...
How can I animate the scroll?
The interactive scroll works if you start it from somewhere else (f.e. Button action) but not from the onAppear modifier. I'd guess this is intentional behavior to prevent the user seeing the scrolling when the view appears (or a bug in SwiftUI...). An ugly workaround is to defer the animation with an DispatchQueue.main.async:
import SwiftUI
struct ContentView: View {
let words = ["planet", "kidnap", "harbor", "legislation", "soap", "management", "prejudice", "an", "trunk", "divide", "critic", "area", "affair"]
#State var selectedWord: String?
var body: some View {
ScrollViewReader { proxy in
VStack(alignment: .leading) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(words, id: \.self) { word in
Text(word)
.background(self.selectedWord == word ? Color.yellow : nil)
.id(word)
}
}
}
Button("Scroll to random word") {
withAnimation(Animation.easeInOut(duration: 1)) {
let word = words.randomElement()
self.selectedWord = word
proxy.scrollTo(word)
}
}
}
.onAppear {
DispatchQueue.main.async { // <--- workaround
withAnimation(Animation.easeInOut(duration: 1).delay(1)) {
let word = self.words.last
self.selectedWord = word
proxy.scrollTo(word)
}
}
}
}
.padding(10)
}
}
Related
I'm trying to setup a toolbar in watchos which displays a button when I scroll the view down. Everything works, the button scrolls down and I can navigate to another page. When returning however, I would like the scrollview to be in the same position as when the app loads, so without the button being visible.
The code I have now is:
struct ContentView: View {
#State private var selectedPage: String? = nil
var body: some View {
ScrollView {
VStack(alignment: .leading) {
ForEach(0..<100) {
Text("Row \($0)")
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Settings") {
selectedPage = "Settings"
}
}
}
.background(
NavigationLink(destination: SettingsView(), tag: "Settings",selection: $selectedPage) {}
.hidden()
)
.navigationTitle {
Text("Navigation")
}
}
}
}
I Have tried using a scrollViewReader, but think I'm looking into the wrong directing as it allows the scrollView to go to a certain position, but the toolbar seems to be no part of it and stays in view. When reading the scrollviews offset position (not in code but check gif), the offset is 0 when the button is not visible (initial state). When scrolling down the offset goes up, but when scrolling up until the button becomes visible the offset of the scrollview is 0 as well.
struct ContentView: View {
#State private var selectedPage: String? = nil
var body: some View {
ScrollView {
ScrollViewReader { reader in
VStack(alignment: .leading) {
ForEach(0..<100) { i in
Text("Row \(i)")
.id(i)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Settings") {
selectedPage = "Settings"
}
}
}
.background(
NavigationLink(destination: SettingsView(), tag: "Settings",selection: $selectedPage) {}
.hidden()
)
.onAppear {
withAnimation {
reader.scrollTo(0, anchor: .top)
}
}
.navigationTitle {
Text("Navigation")
}
}
}
}
}
}
When choosing 1 as value to scroll to the button is pushed back to the top and in the list id 1 is selected.
.onAppear {
withAnimation {
reader.scrollTo(1, anchor: .top)
}
}
So it's working with id 1 and higher, but when using id 0 the view is not reset.
So how to reset the view to the initial state with the button being hidden on top ?
Solved it simply by adding a #State property which is true when loading the view and which changes to false if navigated away from the view and then conditionally show the item within the .toolbar view modifier.
#State var loadedMainView = true
var body: some view {
ScrollView {
// .... code
}
.toolbar {
ToolbarItem {
if loadedMainView {
Button("My Button") {
}
}
}
.onAppear {
loadedMainView = true
}
.onDissappear {
loadedMainView.toggle()
}
I have the following view and I'm attempting to have it scroll to the bottom on button click as elements are added to the list. I've searched and found that ScrollViewReader is the option to use however my implementation doesn't appear to be working.
My attempts at fixing have included explicitly setting the id of the cell on both the inner views as well as the outer HStack{} I even attempted to set the id to a reference of itself, kind of knowing that's a bad idea, but for brevity. I also removed any extra views inside of the list such as HStack{}, Spacer(), etc.. and just left my ColorsChosenView().id(i) thinking that extra views might cause it, but I digress the issue still persists.
var body: some View {
VStack {
ScrollViewReader { reader in
List {
ForEach(0..<vm.guesses.count, id: \.self) { i in
HStack{
Spacer()
ColorsChosenView(locationCorrect: 1,
locationIncorrect: 3,
color1: vm.guesses[i][0],
color2: vm.guesses[i][1],
color3: vm.guesses[i][2],
color4: vm.guesses[i][3])
Spacer()
}.id(i)
}
}.listStyle(InsetListStyle())
Divider()
.frame(maxWidth: 250)
ColorChoicePicker(vm: vm)
Divider()
.frame(maxWidth: 250)
HStack {
Spacer()
FABButton(text: "SUBMIT")
.onTapGesture {
vm.submit()
reader.scrollTo(vm.guesses.count - 1)
}
}.padding()
}
}
.navigationBarHidden(true)
.navigationBarHidden(true)
.onAppear(perform: {
vm.resetGame()
})
}
To simplify things, I found that this works just fine. Yet my implementation doesn't feel much different.
var body: some View {
ScrollViewReader { proxy in
VStack {
Button("Jump to #50") {
proxy.scrollTo(50)
}
List(0..<100, id: \.self) { i in
Text("Example \(i)")
.id(i)
}
}
}
}
Since you're modifying the array, this should work:
1: call the function in the main thread (DispatchQueue.main.async)
-> this will "kinda" work, it will scroll but not to the current but the previous last item
2: (Workaround) handle scrolling in a change-handler (you could also remove the shouldScroll variable if all changes should make it scroll to the bottom)
class NumbersContainer: ObservableObject {
#Published var numbers: [Int] = Array(0..<25)
func submit() {
self.numbers.append(self.numbers.count)
}
}
struct ContentView: View {
#StateObject var nc = NumbersContainer()
#State var shouldScroll: Bool = false
var body: some View {
VStack {
ScrollViewReader { reader in
Button("Submit", action: {
DispatchQueue.main.async {
nc.submit()
}
self.shouldScroll = true
})
List {
ForEach(0..<nc.numbers.count, id: \.self) { i in
HStack {
Spacer()
Text("Row \(i)")
Spacer()
}.id(i)
}
}
.onChange(of: nc.numbers) { newValue in
if shouldScroll {
reader.scrollTo(newValue.count - 1)
shouldScroll = false
}
}
}
}
}
}
Another Possibility would be to use the ScrollReaderProxy as a parameter of the submit function:
class NumbersContainer: ObservableObject {
#Published var numbers: [Int] = Array(0..<25)
func submit(reader: ScrollViewProxy) {
let dispatchGroup = DispatchGroup()
dispatchGroup.enter() // All leaves must have an enter
DispatchQueue.main.async {
self.numbers.append(self.numbers.count)
dispatchGroup.leave() // Notifies the DispatchGroup
}
dispatchGroup.notify(queue: .main) {
reader.scrollTo(self.numbers.count - 1)
}
}
}
struct ContentView: View {
#StateObject var nc = NumbersContainer()
var body: some View {
VStack {
ScrollViewReader { reader in
Button("Submit", action: {
nc.submit(reader: reader)
})
List {
ForEach(0..<nc.numbers.count, id: \.self) { i in
HStack {
Spacer()
Text("Row \(i)")
Spacer()
}.id(i)
}
}
}
}
}
}
I'm trying to use GeometryReader to assign a maxHeight to a list in a Scrollview.
For the purpose of this question, I created the following project : GeometryReaderTesting.
It sets up a ContentView with a :
- Text.
- List.
- Text.
I extracted the List and last Text into their own view, using #ViewBuilder and I want to set the maxHeight of the List to .5 the height of my user's screen.
The problem is that the app won't build with the following errors, and the GeometryReader doesn't seem to be computing the correct height:
Here is my ContentView code, if anyone has an idea of what I'm doing wrong...
struct ContentView: View {
let arrayWithStuff = [ "one","two","three","four","five","six","seven","eight","nine","ten", "eleven", "twelve"]
var heightOfView: CGFloat = 0.0 // To be set by GeometryReader
var body: some View {
let myString = "Top of Test App"
return ZStack {
GeometryReader { g in
heightOfView = g.size.height
NavigationView {
print("height of view : \(heightOfView)")
ScrollView {
VStack {
Text(myString)
.padding()
Divider()
self.ViewBody()
} // END of Vstack
.navigationBarTitle("Test App", displayMode: .inline)
} // END of Scrollview
}//End of NavigationView
} // End of Geometry reader
} // End of Zstack
} // End of body
#ViewBuilder func ViewBody() -> some View {
VStack {
List {
ForEach (self.arrayWithStuff, id: \.self) { item in
Text(item) }
} // END of List
.frame(maxHeight: heightOfView*0.5)
Divider()
Text("Bottom of TEST APP")
}
.padding()
}
}
Again, any help would be appreciated.
Here is a demo of possible solution. Tested with Xcode 11.4.
struct ContentView: View {
let arrayWithStuff = [ "one","two","three","four","five","six","seven","eight","nine","ten", "eleven", "twelve"]
var body: some View {
let myString = "Top of Test App"
return ZStack {
GeometryReader { g in
NavigationView {
ScrollView {
VStack {
Text(myString)
.padding()
Divider()
self.ViewBody(height: g.size.height)
} // END of Vstack
.navigationBarTitle("Test App", displayMode: .inline)
} // END of Scrollview
}//End of NavigationView
} // End of Geometry reader
} // End of Zstack
} // End of body
func ViewBody(height: CGFloat) -> some View {
print("height of view : \(height)")
return VStack {
List {
ForEach (self.arrayWithStuff, id: \.self) { item in
Text(item) }
} // END of List
.frame(height: height*0.5)
Divider()
Text("Bottom of TEST APP")
}
.padding()
}
}
I have a view like following
struct A: View {
var content: AnyView
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack {
// Common Elements
content
// More Common Elements
}
}
}
}
When I call this from another view like
A(nextInnerView())
two things happen. Firstly, as the size of the content element changes ScrollView animates the transition. Secondly, if you scroll down and then change the content the scrolling position does not reset.
Here is a demo of possible solution. Tested with Xcode 11.4 / iOS 13.4
The origin of this behaviour is in SwiftUI rendering optimisation, that tries to re-render only changed part, so approach is to identify view A (to mark it as completely changed) based on condition that originated in interview changes, alternatively it can be identified just by UUID().
struct TestInnerViewReplacement: View {
#State private var counter = 0
var body: some View {
VStack {
Button("Next") { self.counter += 1 }
Divider()
A(content: nextInnerView())
.id(counter) // << here !!
}
}
private func nextInnerView() -> AnyView {
AnyView(Group {
if counter % 2 == 0 {
Text("Text Demo")
} else {
Image(systemName: "star")
.resizable()
.frame(width: 100, height: 100)
}
})
}
}
struct A: View {
var content: AnyView
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack {
ForEach(0..<5) { _ in // upper content demo
Rectangle().fill(Color.yellow)
.frame(height: 40)
.frame(maxWidth: .infinity)
.padding()
}
content
ForEach(0..<10) { _ in // lower content demo
Rectangle().fill(Color.blue)
.frame(height: 40)
.frame(maxWidth: .infinity)
.padding()
}
}
}
}
}
I want to reduce the linespacing in a list to null.
My tries with reducing the padding did not work.
Setting ´.environment(.defaultMinListRowHeight, 0)´ helped a lot.
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
List {
ForEach(data, id: \.self)
{ item in
Text("\(item)")
.padding(0)
//.frame(height: 60)
.background(Color.yellow)
}
//.frame(height: 60)
.padding(0)
.background(Color.blue)
}
.environment(\.defaultMinListRowHeight, 0)
.onAppear { UITableView.appearance().separatorStyle = .none }
.onDisappear { UITableView.appearance().separatorStyle = .singleLine }
}
}
}
Changing the ´separatorStyle´ to ´.none´ only removed the Line but left the space.
Is there an extra ´hidden´ view for the Lists row or for the Separator between the rows?
How can this be controlled?
Would be using ScrollView instead of a List a good solution?
ScrollView(.horizontal, showsIndicators: true)
{
//List {
ForEach(data, id: \.self)
{ item in
HStack{
Text("\(item)")
Spacer()
}
Does it also work for a large dataset?
Well, actually no surprise - .separatorStyle = .none works correctly. I suppose you confused text background with cell background - they are changed by different modifiers. Please find below tested & worked code (Xcode 11.2 / iOS 13.2)
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
List {
ForEach(data, id: \.self)
{ item in
Text("\(item)")
.background(Color.yellow) // text background
.listRowBackground(Color.blue) // cell background
}
}
.onAppear { UITableView.appearance().separatorStyle = .none }
.onDisappear { UITableView.appearance().separatorStyle = .singleLine }
}
}
}
Update:
it's not possible to avoid the blue space between the yellow Texts?
Technically yes, it is possible, however for demo it is used hardcoded values and it is not difficult to fit some, while to calculate this dynamically might be challenging... anyway, here it is
it needs combination of stack for compression, content padding for resistance, and environment for limit:
List {
ForEach(data, id: \.self)
{ item in
HStack { // << A
Text("\(item)")
.padding(.vertical, 2) // << B
}
.listRowBackground(Color.blue)
.background(Color.yellow)
.frame(height: 12) // << C
}
}
.environment(\.defaultMinListRowHeight, 12) // << D
I do it the easy SwiftUI way:
struct ContentView: View {
init() {
UITableView.appearance().separatorStyle = .none
}
var body: some View {
List {
ForEach(0..<10){ item in
Color.green
}
.listRowInsets( EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) )
}
}
}
Reduce row spacing is really tricky, try
struct ContentView: View {
#State var data : [String] = ["first","second","3rd","4th","5th","6th"]
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)").font(.largeTitle)
.background(Color.yellow)
}.background(Color.green)
.padding(.leading, 10)
.padding(.bottom, -25)
.frame(maxWidth: .infinity)
}
}
}
}
}
It use ScrollView instead of List and negative padding.
I didn't find any solution based on List, we have to ask Apple to publish xxxxStyle protocols and underlying structures.
UPDATE
What about this negative padding value? For sure it depends on height of our row content and unfortunately on SwiftUI layout strategy. Lets try some more dynamic content! (we use zero padding to demostrate the problem to solve)
struct ContentView: View {
#State var data : [CGFloat] = [20, 30, 40, 25, 15]
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)").font(.system(size: item))
.background(Color.yellow)
}.background(Color.green)
.padding(.leading, 10)
//.padding(.bottom, -25)
.frame(maxWidth: .infinity)
}
}
}
}
}
Clearly the row spacing is not fixed value! We have to calculate it for every row separately.
Next code snippet demonstrate the basic idea. I used global dictionary (to store height and position of each row) and tried to avoid any high order functions and / or some advanced SwiftUI technic, so it is easy to see the strategy. The required paddings are calculated only once, in .onAppear closure
import SwiftUI
var _p:[Int:(CGFloat, CGFloat)] = [:]
struct ContentView: View {
#State var data : [CGFloat] = [20, 30, 40, 25, 15]
#State var space: [CGFloat] = []
func spc(item: CGFloat)->CGFloat {
if let d = data.firstIndex(of: item) {
return d < space.count ? space[d] : 0
} else {
return 0
}
}
var body: some View {
VStack {
ScrollView {
ForEach(data, id: \.self) { item in
VStack(alignment: .leading, spacing: 0) {
Color.red.frame(height: 1)
Text("\(item)")
.font(.system(size: item))
.background(Color.yellow)
}
.background(
GeometryReader { proxy->Color in
if let i = self.data.firstIndex(of: item) {
_p[i] = (proxy.size.height, proxy.frame(in: .global).minY)
}
return Color.green
}
)
.padding(.leading, 5)
.padding(.bottom, -self.spc(item: item))
.frame(maxWidth: .infinity)
}.onAppear {
var arr:[CGFloat] = []
_p.keys.sorted(by: <).forEach { (i) in
let diff = (_p[i + 1]?.1 ?? 0) - (_p[i]?.1 ?? 0) - (_p[i]?.0 ?? 0)
if diff < 0 {
arr.append(0)
} else {
arr.append(diff)
}
}
self.space = arr
}
}
}
}
}
Running the code I've got