I'm running into an odd issue where on iOS 16, the ScrollViewReaderProxy does not always scroll. It seems as though small content changes won't cause the view to scroll unless the user interacts with the view, or if there was a large content change first. I'm wondering if this is just a bug that exists in iOS 16 (as it works fine in iOS 17) or if there's something I've overlooked.
I want to create a view that can be pinned to the trailing content as more text is added but I also want to have a fading mask on the left and right of the view (hence the off -15 and 15 padding).
TLDR; I have a struct PinnableScrollView<EquatableView: View & Equatable>: View
which takes a @ViewBuilder public let content: EquatableView
so that when the content changes it calls scrollViewReaderProxy.scrollTo
.
Here's the full class that I have:
struct PinnableScrollView<EquatableView: View & Equatable, GenericView: View>: View {
public enum Pinning {
case leading
case trailing
var alignment: Alignment {
switch self {
case .leading: return .leading
case .trailing: return .trailing
}
}
}
public let pinning: Pinning
public let debugViews: Bool
@ViewBuilder public let content: EquatableView
@ViewBuilder public let transformer: (EquatableView) -> GenericView
@State private var contentWidth: CGFloat = .zero
@State private var scrollWidth: CGFloat = .zero
@State private var offsetValue: CGFloat = .zero
@Namespace var element
var canScroll: Bool {
scrollWidth > contentWidth
}
public var body: some View {
if debugViews {
VStack(alignment: .leading, spacing: 10) {
Text("contentWidth: \(contentWidth)").font(.caption2)
Text("scrollWidth: \(scrollWidth)").font(.caption2)
Text("offsetValue: \(offsetValue)").font(.caption2)
scrollContent
}
} else {
scrollContent
}
}
var scrollContent: some View {
ScrollViewReader { scrollViewReaderProxy in
ScrollView(.horizontal) {
HStack(spacing: 0) {
if pinning == .trailing {
Spacer(minLength: 0)
}
transformer(content)
.padding(.horizontal, canScroll ? 15 : 0)
.id(element)
if pinning == .leading {
Spacer(minLength: 0)
}
}
.frame(minWidth: contentWidth)
.background(GeometryReader { proxy in
Color.clear
.preference(
key: ScrollSizePreferenceKey.self,
value: proxy.size.width
).onPreferenceChange(ScrollSizePreferenceKey.self, perform: { value in
self.scrollWidth = value
}).preference(
key: OffsetPreferenceKey.self,
value: proxy.frame(in: .named("scrollViewCoordinateSpaceLayer")).minX
).onPreferenceChange(OffsetPreferenceKey.self, perform: { value in
self.offsetValue = value
})
})
}
.padding(.horizontal, canScroll ? -15 : 0)
.background(GeometryReader { proxy in
Color.clear
.preference(
key: WidthPreferenceKey.self,
value: proxy.size.width
).onPreferenceChange(WidthPreferenceKey.self, perform: { value in
self.contentWidth = value
})
})
.mask(
HStack(spacing: 0) {
LinearGradient(gradient: Gradient(colors: [.black.opacity(0.0), .black]), startPoint: .leading, endPoint: .trailing)
.frame(width: 15.0)
Color.black
.frame(width: contentWidth)
LinearGradient(gradient: Gradient(colors: [.black, .black.opacity(0.0)]), startPoint: .leading, endPoint: .trailing)
.frame(width: 15.0)
}
)
.coordinateSpace(name: "scrollViewCoordinateSpaceLayer")
.onAppear(perform: {
if pinning == .trailing {
scrollViewReaderProxy.scrollTo(element, anchor: .trailing)
}
})
.onChange(of: content, perform: { _ in
if pinning == .trailing {
print("attempting to scroll: \(Date.now)")
withAnimation {
scrollViewReaderProxy.scrollTo(element, anchor: .trailing)
}
}
})
}
}
}
private struct ScrollSizePreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
}
private struct OffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
}
private struct WidthPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
}
And here's a basic preview provider:
struct PinnableScrollView_Previews: PreviewProvider {
struct TestView: View {
@State var string: String = "Hello"
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Button("Add Longer Text") {
string = string + " world, how are you? Are doing well??"
}
Button("Add Short Text") {
string = string + " hello!"
}
PinnableScrollView(pinning: .trailing, debugViews: true) {
Text(string)
} transformer: { view in
view.padding()
}
}
}
}
static var previews: some View {
TestView()
.padding()
}
}