Sync SwiftUI ScrollViews offset bidirectionally - ios

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.

Related

Swift UI ForEach should only be used for *constant* data

So i get this error when running my set game app:
Image of the game screen when more of the same cards appear (All cards in the game should be unique)
ForEach<Range<Int>, Int, ModifiedContent<_ConditionalContent<_ConditionalContent<_ConditionalContent<_ConditionalContent
<ZStack<TupleView<(_ShapeView<Squiggle, ForegroundStyle>,
_StrokedShape<Squiggle>)>>, ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>,
_ClipEffect<Squiggle>>, _StrokedShape<Squiggle>)>>>, _StrokedShape<Squiggle>>,
_ConditionalContent<_ConditionalContent<ZStack<TupleView<(_ShapeView<Capsule, ForegroundStyle>, _StrokedShape<Capsule>)>>,
ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>, _ClipEffect<Capsule>>,
_StrokedShape<Capsule>)>>>, _StrokedShape<Capsule>>>, _ConditionalContent<_ConditionalContent<ZStack<TupleView<(_ShapeView<Diamond,
ForegroundStyle>, _StrokedShape<Diamond>)>>, ZStack<TupleView<(ModifiedContent<_StrokedShape<StripedRect>, _ClipEffect<Diamond>>,
_StrokedShape<Diamond>)>>>, _StrokedShape<Diamond>>>,
_FrameLayout>> count (2) != its initial count (1). `ForEach(_:content:)`
should only be used for *constant* data. Instead conform data to `Identifiable`
or use `ForEach(_:id:content:)` and provide an explicit `id`!
It doesnt seem like the card models underneath the view themselves change. Everything but the numberOfShapes on the cards stay the same. So a card that is displayed wrongly will only match if the underlying model is matched correctly and will not match like shown on view.
The error only seem to appear when i either get a set correct and press "show three more cards" or when i display more cards than is visible and then scroll up and down the screen.
The app doesnt stop with the error, but the numberOfShapes on each card changes dynamically (Like on the screenshot above). (This is most clear when i scroll to the top of the screen and then to the bottom and the number of shapes on some cards changes each time)
So i think the problem might be in the card view:
import SwiftUI
struct CardView: View {
var card: Model.Card
var body: some View {
GeometryReader { geometry in
ZStack{
let shape = RoundedRectangle(cornerRadius: 10)
if !card.isMatched {
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: 4).foregroundColor(card.isSelected ? .red : .blue)
}
VStack {
Spacer(minLength: 0)
//TODO: Error here?
ForEach(0..<card.numberOfShapes.rawValue) { index in
cardShape().frame(height: geometry.size.height/6)
}
Spacer(minLength: 0)
}.padding()
.foregroundColor(setColor())
.aspectRatio(CGFloat(6.0/8.0), contentMode: .fit)
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
#ViewBuilder private func cardShape() -> some View {
switch card.shape {
case .squiglle: shapeFill(shape: Squiggle())
case .roundedRectangle: shapeFill(shape: Capsule())
case .diamond: shapeFill(shape: Diamond())
}
}
private func setColor() -> Color {
switch card.color {
case .pink: return Color.pink
case .orange: return Color.orange
case .green: return Color.green
}
}
#ViewBuilder private func shapeFill<setShape>(shape: setShape) -> some View
//TODO: se løsning, brug rawvalues for cleanness
where setShape: Shape {
switch card.fill {
case .fill: shape.fillAndBorder()
case .stripes: shape.stripe()
case .none: shape.stroke(lineWidth: 2)
}
}
}
The cardviews are displayed in a flexible gridlayout:
import SwiftUI
struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
var items: [Item]
var aspectRatio: CGFloat
var content: (Item) -> ItemView
let maxColumnCount = 6
init(items: [Item], aspectRatio: CGFloat, #ViewBuilder content: #escaping (Item) -> ItemView) {
self.items = items
self.aspectRatio = aspectRatio
self.content = content
}
var body: some View {
GeometryReader { geometry in
VStack {
let width: CGFloat = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)
ScrollView{
LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
ForEach(items) { item in
content(item).aspectRatio(aspectRatio, contentMode: .fit)
}
Spacer(minLength: 0)
}
}
}
}
}
private func adaptiveGridItem(width: CGFloat) -> GridItem {
var gridItem = GridItem(.adaptive(minimum: width))
gridItem.spacing = 0
return gridItem
}
View:
import SwiftUI
struct SetGameView: View {
#StateObject var game: ViewModel
var body: some View {
VStack{
HStack(alignment: .bottom){
Text("Set Game")
.font(.largeTitle)
}
AspectVGrid(items: game.cards, aspectRatio: 2/3) { card in
CardView(card: card)
.padding(4)
.onTapGesture {
game.choose(card)
}
}.foregroundColor(.blue)
.padding(.horizontal)
Button("show 3 more cards"){
game.showThreeMoreCardsFromDeck()
}
}
}
}
Any help solving this is very much appreciated. I have spent so much time on this already.
Your problem is here:
ForEach(0..<card.numberOfShapes.rawValue) { index in
cardShape().frame(height: geometry.size.height/6)
}
This ForEach initializer's first argument is a Range<Int>. The documentation says (emphasis added):
The instance only reads the initial value of the provided data and doesn’t need to identify views across updates. To compute views on demand over a dynamic range, use ForEach/init(_:id:content:).
Because it only reads “the initial value of the provided data”, it's only safe to use if the Range<Int> is constant. If you change the range, the effect is unpredictable. So, starting in Xcode 13, the compiler emits a warning if you pass a non-constant range.
Now, maybe your range (0..<card.numberOfShapes.rawValue) really is constant. But the compiler doesn't know that. (And based on your error, it's not a constant range.)
You can avoid the warning by switching to the initializer that takes an id: argument:
ForEach(0..<card.numberOfShapes.rawValue, id: \.self) { index in
// ^^^^^^^^^^^^^
// add this
cardShape().frame(height: geometry.size.height/6)
}

SwiftUI ScrollView: find subview that is currently visible at center of screen. Subview's position within ScrollView. PreferenceKey. ScrollViewProxy

The Challenge: I would like to track which subview of a ScrollView is in the middle of the visible area of this ScrollView.
The Problem: I understand that there is no native SwiftUI way of finding out whether a ScrollView's subview is currently visible on screen. Or do I miss anything here?
My Solution: I am using PreferenceKey to collect the subviews positions and act onPreferenceChange(s) that happen continuously while the ScrollView is being scrolled: to check for the view closest to the center of the screen.
The Issue: A PreferenceKey based solution works fine for a few subviews. But I need to track up to 3000 subviews (views of a large structured document being constructed out of a database). Performance is not acceptable for a large number of views, even after implementing some optimisations I have been able to come up with.
My questions is: is there
a way to improve the performance of the solution shown below, or
a different way of approaching this challenge
(iOS 14.4 / Xcode 12.4)
private struct ViewOffsetsKey: PreferenceKey {
static var defaultValue: [Int: CGFloat] = [:]
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
value.merge(nextValue(), uniquingKeysWith: { $1 })
}
}
struct ContentView: View {
#State private var offsets: [Int: CGFloat] = [:]
#State private var mainViewHeight: CGFloat = 800 // demo approximation
#State private var highlightItem: Bool = false
#State private var timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
var body: some View {
ZStack(alignment: .top) {
VStack {
ScrollView {
VStack {
ForEach(0..<3000) { i in
Text("Item \(i)")
.id(i)
.padding()
.background(GeometryReader { geo in
Color.clear.preference(
key: ViewOffsetsKey.self,
value: [i: geo.frame(in: .named("scrollView")).origin.y]) })
.overlay((i == middleItemNo && highlightItem) ? Color.orange.opacity(0.5) : Color.clear)
}
}
.onPreferenceChange(ViewOffsetsKey.self, perform: { prefs in
let filteredPrefs = prefs.filter { $1 > 0 && $1 < mainViewHeight }
// Cleaning offsets seams to increase reliablilty.
offsets = [:]
// Dispatch to silence "Bound preference ... update multiple times per frame" warning.
DispatchQueue.main.async {
for pref in filteredPrefs { offsets[pref.key] = pref.value }
}
timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
})
}.coordinateSpace(name: "scrollView")
}
HStack {
Text("Middle item no: \(middleItemNo)")
.padding(5)
.background(Color.white)
Spacer()
}.onReceive(timer) { _ in
highlightItem = true
timer.upstream.connect().cancel()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
highlightItem = false
}
}
}
}
private var middleItemNo: Int {
offsets.sorted(by: { $0 < $1 }).first(where: { $1 >= mainViewHeight / 2 - 100 })?.key ?? 0
}
}
Changing the VStack within the ScrollView for a LazyVStack solves the performance problem. Came up with that idea only after publishing the post. I will leave it online for others to learn from.

Save ScrollViews position and scroll back to it later (offset to position)

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

SwiftUI Drag to Dismiss ScrollView

I'd like to have a drag to dismiss scroll view in SwiftUI, where if you keep dragging when it's at the top of the content (offset 0), it will instead dismiss the view.
I'm working to implement this in SwiftUI and finding it to be rather difficult. It seems like I can either recognize the DragGesture or allowing scrolling, but not both.
I need to avoid using UIViewRepresentable and solve this using pure SwiftUI or get as close as possible. Otherwise it can make developing other parts of my app difficult.
Here's an example of the problem I'm running into:
import SwiftUI
struct DragToDismissScrollView: View {
enum SeenState {
case collapsed
case fullscreen
}
#GestureState var dragYOffset: CGFloat = 0
#State var scrollYOffset: CGFloat = 0
#State var seenState: SeenState = .collapsed
var body: some View {
GeometryReader { proxy in
ZStack {
Button {
seenState = .fullscreen
} label: {
Text("Show ScrollView")
}
/*
* Works like a regular ScrollView but provides updates on the current yOffset of the content.
* Can find code for OffsetAwareScrollView in link below.
* Left out of question for brevity.
* https://gist.github.com/robhasacamera/9b0f3e06dcf27b54962ff0e077249e0d
*/
OffsetAwareScrollView { offset in
self.scrollYOffset = offset
} content: {
ForEach(0 ... 100, id: \.self) { i in
Text("Item \(i)")
.frame(maxWidth: .infinity)
}
}
.background(Color.white)
// If left at the default minimumDistance gesture isn't recognized
.gesture(DragGesture(minimumDistance: 0)
.updating($dragYOffset) { value, gestureState, _ in
// Only want to start dismissing if at the top of the scrollview
guard scrollYOffset >= 0 else {
return
}
gestureState = value.translation.height
}
.onEnded { value in
if value.translation.height > proxy.frame(in: .local).size.height / 4 {
seenState = .collapsed
} else {
seenState = .fullscreen
}
})
.offset(y: offsetForProxy(proxy))
.animation(.spring())
}
}
}
func offsetForProxy(_ proxy: GeometryProxy) -> CGFloat {
switch seenState {
case .collapsed:
return proxy.frame(in: .local).size.height
case .fullscreen:
return max(dragYOffset, 0)
}
}
}
Note: I've tried a lot solutions for the past few days (none that have worked), including:
Adding a delay to the DragGesture using the method mentioned here: https://stackoverflow.com/a/59961959/898984
Adding an empty onTapGesture {} call before the DragGesture as mentioned here: https://stackoverflow.com/a/60015111/898984
Removing the gesture and using the offset provided from the OffsetAwareScrollView when it's > 0. This doesn't work because as the ScrollView is moving down the offset decreases as the OffsetAwareScrollView catches up to the content.

Prevent padding above Image at top of ScrollView when scrolling beyond limit in Sheet

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

Resources