I have found a way to save a ScrollViews offset with a GeometryReader and a PreferenceKey.
SwiftUI | Get current scroll position from ScrollView
And the ScrollViewReader has a method scrollTo to scroll to a set position.
scrollTo
The problem is, that the first one saves an offset while the second method expects a position (or an id, which is similar to the position in my case). How can I convert the offset to a position/id or is there any other way to save and load a ScrollViews position?
Here is the code I have now but it does not scroll the way I want:
ScrollView {
ScrollViewReader { scrollView in
LazyVGrid(columns: columns, spacing: 0) {
ForEach(childObjects, id: \.id) { obj in
CustomView(obj: obj).id(obj.id)
}
}
.onChange(of: scrollTarget) { target in
if let target = target {
scrollTarget = nil
scrollView.scrollTo(target, anchor: .center)
}
}
.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { // save $0 }
}
}.coordinateSpace(name: "scroll")
And in the onAppear of the View I want to set scrollTarget to the saved position. But it scrolls anywhere but not to the position I want.
I thought about dividing the offset by the size of one item but is that really the way to go? It does not sound very good.
You don't need actually offset in this scenario, just store id of currently visible view (you can use any appropriate algorithm for your data of how to detect it) and then scroll to view with that id.
Here is a simplified demo of possible approach. Tested with Xcode 12.1/iOS 14.1
struct TestScrollBackView: View {
#State private var stored: Int = 0
#State private var current: [Int] = []
var body: some View {
ScrollViewReader { proxy in
VStack {
HStack {
Button("Store") {
// hard code is just for demo !!!
stored = current.sorted()[1] // 1st is out of screen by LazyVStack
print("!! stored \(stored)")
}
Button("Restore") {
proxy.scrollTo(stored, anchor: .top)
print("[x] restored \(stored)")
}
}
Divider()
ScrollView {
LazyVStack {
ForEach(0..<1000, id: \.self) { obj in
Text("Item: \(obj)")
.onAppear {
print(">> added \(obj)")
current.append(obj)
}
.onDisappear {
current.removeAll { $0 == obj }
print("<< removed \(obj)")
}.id(obj)
}
}
}
}
}
}
}
Related
I'm trying to place multiple ScrollViews in a view and sync their scroll offset bidirectionally. Let's say there are ScrollView-A and ScrollView-B, if I scroll A, I want B to scroll the same distance, and vice versa.
My code is in-progress as below. It shares one offset State and both ScrollViews update it depending on their own scroll offset.
ScrollView-B uses it with the .offset modifier to catch up with ScrollView-A's scrolling.
However, the thing is, ScrollView-B tracks its own offset and updates the PreferenceKey so that it can update the scroll offset bidirectionally. Hence, it triggers Preference change again and again... kind of infinite loop. Got the following message in the console.
Bound preference ViewOffsetKey tried to update multiple times per frame.
Is there any workaround for that? Better solutions for the same goal are also very welcome.
#State private var offset: CGFloat = .zero
TabView {
// ScrollView-A
ScrollView {
VStack {
ForEach(0..<100, id: \.self) { index in
Text("Cell \(index)")
}
}
.background(GeometryReader {
Color.clear.preference(
key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).minY)
})
.onPreferenceChange(ViewOffsetKey.self) { offset = $0 }
//.offset(y: -offset)
}
.coordinateSpace(name: "scroll")
// ScrollView-B
ScrollView {
VStack {
ForEach(0..<10, id: \.self) { index in
Text("Cell \(index)")
}
}
.background(GeometryReader {
Color.clear.preference(
key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll2")).minY)
})
.onPreferenceChange(ViewOffsetKey.self) { offset = $0 }
.offset(y: -offset) // It triggers PreferenceChange again and again...
}
.coordinateSpace(name: "scroll2")
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
UPDATE
I found the above warning message is gone if I move the .offset(y: -offset) before the definition of .background(GeometryReader ...
It seems to be my misunderstanding on iOS 16 simulator. I tried on iOS 15 real device and the same warning still appears.
I have the following code:
struct TestItem:Identifiable {
var id = UUID()
var index:Int
}
struct ContentView: View {
#State var testItems = [TestItem]()
let itemsUpperBound:Int = 1000
#State var trigger = false
var columnGridItems = [
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
VStack {
HStack {
Button {
trigger.toggle()
print("trigger: \(trigger)")
} label: {
Text("TEST")
.foregroundColor(.white)
.padding()
.background(.blue)
}
Spacer()
}
ScrollView(.horizontal) {
ScrollViewReader { sr in
LazyHGrid(rows: columnGridItems) {
ForEach(testItems) { ti in
Text("id \(ti.id)")
}
}
.border(.blue)
.onChange(of: trigger) { newValue in
withAnimation(.easeInOut(duration: 10)) {
let idx = Int.random(in: 0..<testItems.count)
let id = testItems[idx].id
print("will try to scroll to id: \(id)")
sr.scrollTo(id)
}
}
}
}
.border(.red)
}
.onAppear {
for i in 0..<itemsUpperBound {
testItems.append(TestItem(index: i))
}
}
}
}
I would like to animate the scroll view content offset, with whatever duration I'd like to set, so that the app would show the entire content of the scrollview without requiring the user to scroll it. In UIKit I could do it by animating the content offset, but, I am not sure how to do this in SwiftUI.
How can I animate the content offset of a scrollview in SwiftUI to achieve this type of animation?
I have edited the code to account for Asperi's comment and tried to use a ScrollReader. It does scroll to the desired item in the scrollview, but the duration is not working. I set it to 10 seconds, and it doesn't seem to do anything.
How can I animate the scroll using a custom duration I want?
I am trying to inset the bottom of a list by the height of the keyboard when the keyboard shows and the list is scrolled to the bottom. I know this question has been asked in different scenarios but I haven't found any proper solution yet. This is specifically for a chat app and the simple code below demonstrates the problem:
#State var text: String = ""
#FocusState private var keyboardVisible: Bool
var body: some View {
NavigationView {
VStack {
List {
ForEach(1...100, id: \.self) { item in
Text("\(item)")
}
}
.navigationTitle("Conversations")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
keyboardVisible = false
} label: {
Text("Hide Keyboard")
}
}
}
ZStack {
Color.red
.frame(height: 44)
TextEditor(text: $text)
.frame(height: 32)
.focused($keyboardVisible)
}
}
}
}
If you scroll to the end and tap in the textEditor, the text editor moves up with the keyboard as expected, but the list doesn't move the content up. I wonder how I can achieve this and have it move up smoothly with the keyboard.
You don't mention trying it, but this is exactly what we have a ScrollViewReader() for. There is a bit of an issue using it in this case, that can be worked around. The issue is that keyboardVisible changes BEFORE the keyboard is fully up. If you scroll at that point, you will cut off the bottom of the List entries. So, we need to delay the reader with DispatchQueue.main.asyncAfter(deadline:). This causes enough delay that when the reader actually reads the position, the keyboard is at full height, and the scroll to the bottom does its job.
#State var text: String = ""
#FocusState private var keyboardVisible: Bool
var body: some View {
NavigationView {
VStack {
ScrollViewReader { scroll in
List {
ForEach(1...100, id: \.self) { item in
Text("\(item)")
}
}
.onChange(of: keyboardVisible, perform: { _ in
if keyboardVisible {
withAnimation(.easeIn(duration: 1)) {
scroll.scrollTo(100) // this would be your array.count - 1,
// but you hard coded your ForEach
}
// The scroll has to wait until the keyboard is fully up
// this causes it to wait just a bit.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// this helps make it look deliberate and finished
withAnimation(.easeInOut(duration: 1)) {
scroll.scrollTo(100) // this would be your array.count - 1,
// but you hard coded your ForEach
}
}
}
})
.navigationTitle("Conversations")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
keyboardVisible = false
} label: {
Text("Hide Keyboard")
}
}
}
ZStack {
Color.red
.frame(height: 44)
TextEditor(text: $text)
.frame(height: 32)
.focused($keyboardVisible)
}
}
}
}
}
Edit:
I changed the code to scroll twice. The first starts the scroll immediately, and the second scrolls it after the keyboard is up to finish the job. It starts quickly and ends smoothly. I also left the comments in the code for the next person; you don't need them.
I'm trying to make my ScrollView:
Not bounce when the content is smaller than the screen
Bounce when the content overflows the screen
Here's my code:
struct ContentView: View {
init() {
UIScrollView.appearance().alwaysBounceVertical = false
}
var body: some View {
ScrollView {
Rectangle()
.fill(Color.blue)
.frame(height: 300) /// is smaller than the screen
.padding()
}
}
}
I tried setting UIScrollView.appearance().alwaysBounceVertical = false, but the scroll view still bounces:
If I do UIScrollView.appearance().bounces = false, it stops bouncing. However, if I make the rectangle taller than the screen, it also stops bouncing (which I don't want).
Doesn't bounce (yay!)
... but doesn't bounce when the content overflows the screen
How can I disable bouncing, but only when the content is smaller than scroll view's bounds?
Well, when using SwiftUI-Introspect, setting alwaysBounceVertical to false actually works. I'm not exactly sure why setting the appearance as you did doesn't work though...
Anyway, here is some working example code:
struct ContentView: View {
#State private var count = 5
var body: some View {
VStack {
Picker("Count", selection: $count) {
Text("5").tag(5)
Text("100").tag(100)
}
.pickerStyle(SegmentedPickerStyle())
ScrollView {
VStack {
ForEach(1 ... count, id: \.self) { i in
Text("Item: \(i)")
}
}
.frame(maxWidth: .infinity)
}
.introspectScrollView { scrollView in
scrollView.alwaysBounceVertical = false
}
}
}
}
Result (unfortunately you can't see my mouse trying to drag the shorter list):
From iOS 16.4 onwards, there is a new modifier for called .scrollBounceBehavior that can be used to prevent ScrollView from bouncing when the content is smalled than the screen.
Example:
struct ContentView: View {
var body: some View {
ScrollView {
Rectangle()
.fill(Color.blue)
.frame(height: 300)
.padding()
}
.scrollBounceBehavior(.basedOnSize)
}
}
Made a small extension based on George's answer using SwiftUI-Introspect:
extension View {
func disableBouncingWhenNotScrollable() -> some View {
introspectScrollView { scrollView in
scrollView.alwaysBounceVertical = false
scrollView.alwaysBounceHorizontal = false
}
}
}
Usage:
ScrollView {
// content
}
.disableBouncingWhenNotScrollable()
If you present a Sheet that displays an Image with Text underneath in a ScrollView, when you scroll down then flick back up, notice that it'll scroll beyond the limit and empty space appears above the image until the elastic scroll effect settles. You can then pull down to dismiss the Sheet.
I want to prevent adding space above the Image in the ScrollView. Instead, I want that padding to appear in-between the Image and the first Text. An app that does this is the App Store - when you tap a story in Today, notice the image at the top always remains fixed to the top when scrolled up past the limit, and the elastic bounce effect occurs underneath it.
I suspect GeometryReader could be utilized to get the position in the global CoordinateSpace, but it's not clear to me how to utilize that to obtain the desired behavior.
struct ContentView: View {
#State private var sheetVisible = false
var body: some View {
VStack {
Button(action: {
sheetVisible = true
}, label: {
Text("Present Sheet")
})
}
.sheet(isPresented: $sheetVisible) {
DetailsView(image: UIImage(named: "test"))
}
}
}
struct DetailsView: View {
var image: UIImage?
var body: some View {
ScrollView {
Image(uiImage: image ?? UIImage())
.resizable()
.aspectRatio((image?.size.width ?? 1) / (image?.size.height ?? 1), contentMode: .fill)
.background(Color(.secondarySystemFill))
ForEach(0..<50) { index in
Text("\(index)")
}
}
}
}
Ok, here is possible solution for your case. Tested with Xcode 12b5 / iOS 14.
The idea is to have internal container, that scrolls inside, but reading its coordinate in scroll view coordinate space, compensate image position offsetting it relative to container, which continue scrolling, such giving effect that everything else, ie below image, still has bouncing.
struct DemoView: View {
var image: UIImage?
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack { // internal container
Image(uiImage: image ?? UIImage())
.resizable()
.aspectRatio((image?.size.width ?? 1) / (image?.size.height ?? 1), contentMode: .fill)
.background(Color(.secondarySystemFill))
.offset(y: offset < 0 ? offset : 0) // compansate offset
ForEach(0..<50) { index in
Text("\(index)")
}
}
.background(GeometryReader {
// read current position of container inside scroll view
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) {
self.offset = $0 // needed offset to shift back image
}
}
.coordinateSpace(name: "scroll")
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
This is possible using GeometryReader:
Wrap the Image in GeometryReader so it's possible to get the position in the global coordinate space.
Make sure to add .scaledToFill() to the GeometryReader because otherwise it won't take up any space in a ScrollView. Alternatively you could solve this by giving the GeometryReader a default frame
Use the minY of the Image in the global coordinate space to set the offset when 'dragging up'. This way it's simulated the image is in a fixed position.
There is a problem with this technique. When dragging the sheet down the image will stick to it's position, which looks weird. I haven't found out how to fix that but maybe this answer will help you.
See the example below.
struct DetailsView: View {
func getOffsetY(basedOn geo: GeometryProxy) -> CGFloat {
// Find Y position
let minY = geo.frame(in: .global).minY
let emptySpaceAboveSheet: CGFloat = 40
// Don't offset view when scrolling down
if minY <= emptySpaceAboveSheet {
return 0
}
// Offset the view when it goes above to simulate it standing still
return -minY + emptySpaceAboveSheet
}
var body: some View {
ScrollView {
GeometryReader { imageGeo in
Image(systemName: "option")
.resizable()
.scaledToFill()
.background(Color(.secondarySystemFill))
.offset(x: 0, y: self.getOffsetY(basedOn: imageGeo))
}
// Need this to make sure the geometryreader has a size
.scaledToFill()
ForEach(0..<50) { index in
Text("\(index)")
}
}
}
}