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.
Related
I have a SwiftUI app which supports both LTR and RTL languages. It is not fixated to either one of those, it just depends on what user wants.
I have a horizontally scrollable list of things inside a vertically scrollable list of things, previously achieved by UICollectionView inside a UITableViewCell inside a UITableView.
For RTL, the horizontally scrollable row needs to be on the first element by default which is the rightmost (opposite of LTR obviously). I have found no way yet to make this happen.
I tried flipsForRightToLeftLayoutDirection(_:) but the problem is it flips everything, including the Text() inside the scrolling views. So the Arabic text looks mirrored.
Here is my skeleton of the view -
ScrollView(.horizontal) {
LazyHStack {
ForEach(things) { thing in
Text(thing.name)
}
}
}
Any ideas on how to achieve this? Thanks in advance.
To automatically scroll to the rightmost element you can use the scrollTo function of the ScrollViewReader view.
You should set an id to each element of the ForEach loop and use the onAppear modifier of the ForEach to scroll to the rightmost element (the last id). This should be done only if language direction is right to left (Locale.characterDirection gives this information).
The code below illustrates this:
struct LanguageScrollView: View {
let things: [String] = ["Thing1", "Thing2", "Thing3", "Thing4", "Thing5", "Thing6", "Thing7", "Thing8", "Thing9", "Thing10"]
var body: some View {
ScrollView(.horizontal) {
ScrollViewReader { value in
LazyHStack {
ForEach(0..<things.count) { i in
Text(things[i])
.id(i)
}
.onAppear {
if getLangDirection() == .rightToLeft {
value.scrollTo(things.count - 1)
}
}
}
}
}
}
func getLangDirection() -> Locale.LanguageDirection? {
guard let language = Locale.current.languageCode else { return nil }
let direction = Locale.characterDirection(forLanguage: language)
return direction
}
}
#MatM answer above is correct, however, I LazyHStack has a problem with sizing (on iOS 14 at least) if we don't set the frame explicitly, which I haven't had any idea why. Another thing instead uses Locale.LanguageDirection? I prefer to use layoutDirection from Environment like below.
Keep in mind I wrote to use HStack because in Lazy I have a problem with the view is not properly showing for RTL somehow except we define the frame.
#Environment(\.layoutDirection) var layoutDirection
ScrollView(.horizontal) {
ScrollViewReader { value in
HStack {
ForEach(0..<things.count) { i in
Text(things[i])
.id(i)
}
.onAppear {
// change things.first with whatever is the first identifier you set
proxy.scrollTo(things.first ?? 0, anchor: layoutDirection == LayoutDirection.rightToLeft ? .trailing : .leading)
}
}
}
}
The following will be very helpful.
import SwiftUI
struct ScrollViewRTL<Content: View>: View {
#ViewBuilder var content: Content
#Environment(\.layoutDirection) private var layoutDirection
var type: RowType
init(type: RowType, #ViewBuilder content: () -> Content) {
self.type = type
self.content = content()
}
#ViewBuilder var body: some View {
ScrollView(type.scrollAxis, showsIndicators: false) {
content
.rotation3DEffect(Angle(degrees: layoutDirection == .rightToLeft ? -180 : 0), axis: (
x: CGFloat(0),
y: CGFloat(layoutDirection == .rightToLeft ? -10 : 0),
z: CGFloat(0)))
}
.rotation3DEffect(Angle(degrees: layoutDirection == .rightToLeft ? 180 : 0), axis: (
x: CGFloat(0),
y: CGFloat(layoutDirection == .rightToLeft ? 10 : 0),
z: CGFloat(0)))
}
}
public enum RowType {
case hList
case vList
var scrollAxis: Axis.Set {
switch self {
case .hList:
return .horizontal
case .vList:
return .vertical
}
}
}
In my app I have a swipeable card view that can be dragged left or right.
When the user drags the card left or right, the card's horizontal offset is updated. When the user releases the drag, the card moves off the screen (either left or right, depending on the drag direction) or the card goes back to the initial position if the horizontal offset did not exceed the threshold.
It works well, but if the user touches the card view with another finger while dragging and then takes his/her fingers off screen, the card position freezes, and it doesn't either move off the screen or go back to the initial position. I debugged the code and it turns out that in that case the DragGesture().onEnded event does not fire.
I am looking for any hints on how I can detect this situation.
Here is the code:
If I had something like isTouchingScreen state, I would be able to solve this.
EDIT: Here is a minimal example where the problem manifests itself.
import SwiftUI
struct ComponentPlayground: View {
#State private var isDragging: Bool = false
#State private var horizontalOffset: CGFloat = .zero
var background: Color {
if abs(horizontalOffset) > 100 {
return horizontalOffset < 0 ? Color.red : Color.green
} else {
return Color.clear
}
}
var body: some View {
GeometryReader { geometry in
Color.white
.cornerRadius(15)
.shadow(color: Color.gray, radius: 5, x: 2, y: 2)
.overlay(background.cornerRadius(15))
.rotationEffect(.degrees(Double(horizontalOffset / 10)), anchor: .bottom)
.offset(x: horizontalOffset, y: 0)
.gesture(
DragGesture()
.onChanged { gesture in
self.isDragging = true
self.horizontalOffset = gesture.translation.width
}
.onEnded { gesture in
self.isDragging = false
if abs(horizontalOffset) > 100 {
withAnimation {
self.horizontalOffset *= 5
}
} else {
withAnimation {
self.horizontalOffset = .zero
}
}
}
)
.frame(width: 300, height: 500)
}
}
}
struct ComponentPlayground_Previews: PreviewProvider {
static var previews: some View {
VStack {
Spacer()
HStack(alignment: .center) {
ComponentPlayground()
}
.frame(width: 300, height: 500)
Spacer()
}
}
}
Since there is no update on the issue from Apple, I will share my workaround for this problem.
Workaround still does not provide drag functionality similar to UIPanGesture from UIKit, but still...
First I've created two GestureStates variables and keep updating them in my DragGesture:
#GestureState private var stateOffset: CGSize = CGSize.zero
#GestureState private var isGesturePressed: Bool = false
let dragGesture = DragGesture()
.updating($stateOffset) { value, gestureState, transaction in
gestureState = CGSize(width: value.translation.width, height: value.translation.height)
}
.updating($isGesturePressed) { value, gestureState, transaction in
gestureState = true
}
By updating "isGesturePressed"(by making it true while gesture is active) I will know if the gesture is actually pressed or not. And while it pressed, I do my animation code with stateOffset:
SomeView()
.offset(someCustomOffset)
.gesture(dragGesture)
.onChange(of: stateOffset) { _ in
if isGesturePressed {
withAnimation { Your Animation Code here
}
} else {
withAnimation { Your Animation Code here for when the gesture is no longer pressed. This is basically my .onEnd function now.
}
}
As I said above. It will not provide the usually UX (e.g. Twitter side menu), cause our animation just immeadiatly ends when another finger presses the screen during the on-going animation. But at least it is no longer stuck mid-way.
There are definitely more elegant and better written workarounds for this, but I hope it may help someone.
I want to implement a Pager that allows a user to move between two views by swiping left or right. Each of these views is a ScrollView that contains many elements (mostly VStacks and HStacks with Text elements, but there are also horizontal ScrollViews containing Text elements).
I attach a highPriorityGesture(DragGesture()) to the Pager and as the user drags I offset the views so that one view becomes visible and the other slides off the screen. In the drag gesture's .onEnded callback if translation.width predicatedTranslation.width exceeds a threshold then I switch the view (so that the user doesn't have to swipe through the whole screen, but only half of it) and if it doesn't exceed the threshold I make the view snap back to its initial position.
The problem appears when one of the two views embedded in the Pager contains a horizontal ScrollView. Then the highPriorityGesture(DragGesture()) misbehaves. What happens is the following:
the horizontal ScrollView scrolls to the left/right
the highPriorityGesture(DragGesture()) picks up that drag and .onChanged callback fires and updates the offset, but the .onEnded callback never fires
The result is that the Pager moves one view a bit to the left/right, but since the .onEnded callback doesn't fire it just leave that view there instead of making it move back to the initial position (so it might end in showing 95% of the first view and 5% of the second view).
I also tried using .gesture(DragGesture()) instead of highPriorityGesture(DragGesture()), but it still picks up the drag from the child ScrollView.
Is there any way of making the child horizontal ScrollView to exclusively handle the its drag?
EDIT:
It happens also if one of the two views is simply a vertical ScrollView without any nested horizontal ScrollViews. Somehow even though it is vertical it can be dragged diagonally.
struct Pager2View<T1: View, T2: View>: View {
let pageCount: Int
let pageOne: T1
let pageTwo: T2
#Binding var currentIndex: Int
#State private var translation: CGSize = .zero
#State private var offset: CGFloat = 0.0
func exceedsThreshold(gesture: DragGesture.Value, geometryWidth: CGFloat) -> Bool {
(abs(gesture.translation.width) > (geometryWidth / 2)) || (abs(gesture.predictedEndTranslation.width) > (geometryWidth / 2))
}
var body: some View {
GeometryReader { geometry in
HStack {
self.pageOne.frame(width: geometry.size.width)
self.pageTwo.frame(width: geometry.size.width)
}
.frame(width: geometry.size.width, alignment: .leading)
.offset(x: self.offset)
.onChange(of: currentIndex) { value in
withAnimation {
if currentIndex == 0 {
self.translation.width = 0
} else {
self.translation.width = -geometry.size.width
}
}
}
.onChange(of: translation) { value in
withAnimation {
self.offset = max(-geometry.size.width, min(0, -geometry.size.width * CGFloat(currentIndex) + self.translation.width))
}
}
.highPriorityGesture(
DragGesture()
.onChanged { gesture in
self.translation = gesture.translation
}
.onEnded { gesture in
if exceedsThreshold(gesture: gesture, geometryWidth: geometry.size.width) {
if gesture.translation.width < 0 && exceedsThreshold(gesture: gesture, geometryWidth: geometry.size.width) && currentIndex == 0 {
withAnimation {
self.translation.width = 0
currentIndex = min(self.pageCount - 1, currentIndex + 1)
}
} else if gesture.translation.width > 0 && exceedsThreshold(gesture: gesture, geometryWidth: geometry.size.width) && currentIndex == 1 {
withAnimation {
self.translation.width = geometry.size.width
currentIndex = max(0, currentIndex - 1)
}
}
} else {
withAnimation(.interactiveSpring()) {
if currentIndex == 0 {
self.translation.width = 0
} else {
self.translation.width = -geometry.size.width
}
}
}
}
)
}
}
}
Hiding the navigation bar on scroll was supported in Swift with navigationController?.hidesBarsOnSwipe = true
To be clear, I'd like it to only be hidden on scroll, so .navigationBarHidden(true) would not suffice.
I tried accessing the NavigationController as described in this Stackoverflow answer, (I added nc.hidesBarsOnSwipe = true) and while it compiled, it did not work.
Is this supported in SwiftUI?
NavigationView seems to be relatively buggy still. For example, by default a ScrollView will ignore the title area and just scroll beneath it.
It looks to me like you can get this working by using displayMode: .inline and StackNavigationViewStyle() together.
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
ForEach(0...20, id: \.self) { count in
(count % 2 == 0 ? Color.red : Color.blue)
.frame(height: 44.0)
}
}
.background(NavigationConfigurator { nc in // NavigationConfigurator is from the OP's post: https://stackoverflow.com/a/58427754/7834914
nc.hidesBarsOnSwipe = true
})
.navigationBarTitle("Hello World", displayMode: .inline)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
Before Scroll:
After Scroll:
I've come across the same problem. Here's how i solved it.
get the scroll offset of the view
hide or view nav bar according to the offset
1. getting the scroll position
Please see here for how to do this. I'll add a sample code here.
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
import SwiftUI
struct ObservableScrollView<Content>: View where Content : View {
#Namespace var scrollSpace
#Binding var scrollOffset: CGFloat
let content: () -> Content
init(scrollOffset: Binding<CGFloat>,
#ViewBuilder content: #escaping () -> Content) {
_scrollOffset = scrollOffset
self.content = content
}
var body: some View {
ScrollView {
content()
.background(GeometryReader { geo in
let offset = -geo.frame(in: .named(scrollSpace)).minY
Color.clear
.preference(key: ScrollViewOffsetPreferenceKey.self,
value: offset)
})
}
.coordinateSpace(name: scrollSpace)
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
scrollOffset = value
}
}
}
2. hide or view nav bar according to the offset
Now we can use the above created observable scroll view.
#State var scrollOffset: CGFloat = CGFloat.zero
#State var hideNavigationBar: Bool = false
var body: some View {
NavigationView {
ObservableScrollView(scrollOffset: self.$scrollOffset) {
Text("I'm observable")
}
.navigationTitle("Title")
.onChange(of: scrollOffset, perform: { scrollOfset in
let offset = scrollOfset + (self.hideNavigationBar ? 50 : 0) // note 1
if offset > 60 { // note 2
withAnimation(.easeIn(duration: 1), {
self.hideNavigationBar = true
})
}
if offset < 50 {
withAnimation(.easeIn(duration: 1), {
self.hideNavigationBar = false
})
}
})
.navigationBarHidden(hideNavigationBar)
}
}
Note 1: Assume that the height of the navigation title is 50. (This will change depending on the style.) When the nav bar dissapears, scroll offset drops by that height instantly. To keep the offset consistant add the height of the nav bar to the offset if it's hidden.
Note 2: I intentionally let a small difference between two thresholds for hiding and showing instead of using the same value, Because if the user scrolls and keep it in the threshold it won't flicker.
It looks like in current tools/system, just released Xcode 11.4 / iOS 13.4, there will be no SwiftUI-native support for "scroll-to" feature in List. So even if they, Apple, will provide it in next major released, I will need backward support for iOS 13.x.
So how would I do it in most simple & light way?
scroll List to end
scroll List to top
and others
(I don't like wrapping full UITableView infrastructure into UIViewRepresentable/UIViewControllerRepresentable as was proposed earlier on SO).
SWIFTUI 2.0
Here is possible alternate solution in Xcode 12 / iOS 14 (SwiftUI 2.0) that can be used in same scenario when controls for scrolling is outside of scrolling area (because SwiftUI2 ScrollViewReader can be used only inside ScrollView)
Note: Row content design is out of consideration scope
Tested with Xcode 12b / iOS 14
class ScrollToModel: ObservableObject {
enum Action {
case end
case top
}
#Published var direction: Action? = nil
}
struct ContentView: View {
#StateObject var vm = ScrollToModel()
let items = (0..<200).map { $0 }
var body: some View {
VStack {
HStack {
Button(action: { vm.direction = .top }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { vm.direction = .end }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
ScrollViewReader { sp in
ScrollView {
LazyVStack {
ForEach(items, id: \.self) { item in
VStack(alignment: .leading) {
Text("Item \(item)").id(item)
Divider()
}.frame(maxWidth: .infinity).padding(.horizontal)
}
}.onReceive(vm.$direction) { action in
guard !items.isEmpty else { return }
withAnimation {
switch action {
case .top:
sp.scrollTo(items.first!, anchor: .top)
case .end:
sp.scrollTo(items.last!, anchor: .bottom)
default:
return
}
}
}
}
}
}
}
}
SWIFTUI 1.0+
Here is simplified variant of approach that works, looks appropriate, and takes a couple of screens code.
Tested with Xcode 11.2+ / iOS 13.2+ (also with Xcode 12b / iOS 14)
Demo of usage:
struct ContentView: View {
private let scrollingProxy = ListScrollingProxy() // proxy helper
var body: some View {
VStack {
HStack {
Button(action: { self.scrollingProxy.scrollTo(.top) }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { self.scrollingProxy.scrollTo(.end) }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
List {
ForEach(0 ..< 200) { i in
Text("Item \(i)")
.background(
ListScrollingHelper(proxy: self.scrollingProxy) // injection
)
}
}
}
}
}
Solution:
Light view representable being injected into List gives access to UIKit's view hierarchy. As List reuses rows there are no more values then fit rows into screen.
struct ListScrollingHelper: UIViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeUIView(context: Context) -> UIView {
return UIView() // managed by SwiftUI, no overloads
}
func updateUIView(_ uiView: UIView, context: Context) {
proxy.catchScrollView(for: uiView) // here UIView is in view hierarchy
}
}
Simple proxy that finds enclosing UIScrollView (needed to do once) and then redirects needed "scroll-to" actions to that stored scrollview
class ListScrollingProxy {
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: UIScrollView?
func catchScrollView(for view: UIView) {
if nil == scrollView {
scrollView = view.enclosingScrollView()
}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentSize.height +
scroller.contentInset.bottom + scroller.contentInset.top - 1
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
scroller.scrollRectToVisible(rect, animated: true)
}
}
}
extension UIView {
func enclosingScrollView() -> UIScrollView? {
var next: UIView? = self
repeat {
next = next?.superview
if let scrollview = next as? UIScrollView {
return scrollview
}
} while next != nil
return nil
}
}
Just scroll to the id:
scrollView.scrollTo(ROW-ID)
Since SwiftUI structured designed Data-Driven, You should know all of your items IDs. So you can scroll to any id with ScrollViewReader from iOS 14 and with Xcode 12
struct ContentView: View {
let items = (1...100)
var body: some View {
ScrollViewReader { scrollProxy in
ScrollView {
ForEach(items, id: \.self) { Text("\($0)"); Divider() }
}
HStack {
Button("First!") { withAnimation { scrollProxy.scrollTo(items.first!) } }
Button("Any!") { withAnimation { scrollProxy.scrollTo(50) } }
Button("Last!") { withAnimation { scrollProxy.scrollTo(items.last!) } }
}
}
}
}
Note that ScrollViewReader should support all scrollable content, but now it only supports ScrollView
Preview
Preferred way
This answer is getting more attention, but I should state that the ScrollViewReader is the right way to do this. The introspect way is only if the reader/proxy doesn't work for you, because of a version restrictions.
ScrollViewReader { proxy in
ScrollView(.vertical) {
TopView().id("TopConstant")
...
MiddleView().id("MiddleConstant")
...
Button("Go to top") {
proxy.scrollTo("TopConstant", anchor: .top)
}
.id("BottomConstant")
}
.onAppear{
proxy.scrollTo("MiddleConstant")
}
.onChange(of: viewModel.someProperty) { _ in
proxy.scrollTo("BottomConstant")
}
}
The strings should be defined in one place, outside of the body property.
Legacy answer
Here is a simple solution that works on iOS13&14:
Using Introspect.
My case was for initial scroll position.
ScrollView(.vertical, showsIndicators: false, content: {
...
})
.introspectScrollView(customize: { scrollView in
scrollView.scrollRectToVisible(CGRect(x: 0, y: offset, width: 100, height: 300), animated: false)
})
If needed the height may be calculated from the screen size or the element itself.
This solution is for Vertical scroll. For horizontal you should specify x and leave y as 0
Thanks Asperi, great tip. I needed to have a List scroll up when new entries where added outside the view. Reworked to suit macOS.
I took the state/proxy variable to an environmental object and used this outside the view to force the scroll. I found I had to update it twice, the 2nd time with a .5sec delay to get the best result. The first update prevents the view from scrolling back to the top as the row is added. The 2nd update scrolls to the last row. I'm a novice and this is my first stackoverflow post :o
Updated for MacOS:
struct ListScrollingHelper: NSViewRepresentable {
let proxy: ListScrollingProxy // reference type
func makeNSView(context: Context) -> NSView {
return NSView() // managed by SwiftUI, no overloads
}
func updateNSView(_ nsView: NSView, context: Context) {
proxy.catchScrollView(for: nsView) // here NSView is in view hierarchy
}
}
class ListScrollingProxy {
//updated for mac osx
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}
private var scrollView: NSScrollView?
func catchScrollView(for view: NSView) {
//if nil == scrollView { //unB - seems to lose original view when list is emptied
scrollView = view.enclosingScrollView()
//}
}
func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentView.frame.minY
if let documentHeight = scroller.documentView?.frame.height {
rect.origin.y = documentHeight - scroller.contentSize.height
}
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
//tried animations without success :(
scroller.contentView.scroll(to: NSPoint(x: rect.minX, y: rect.minY))
scroller.reflectScrolledClipView(scroller.contentView)
}
}
}
extension NSView {
func enclosingScrollView() -> NSScrollView? {
var next: NSView? = self
repeat {
next = next?.superview
if let scrollview = next as? NSScrollView {
return scrollview
}
} while next != nil
return nil
}
}
my two cents for deleting and repositioning list at any point based on other logic.. i.e. after delete/update, for example going to top.
(this is a ultra-reduced sample, I used this code after network call back to reposition: after network call I change previousIndex )
struct ContentView: View {
#State private var previousIndex : Int? = nil
#State private var items = Array(0...100)
func removeRows(at offsets: IndexSet) {
items.remove(atOffsets: offsets)
self.previousIndex = offsets.first
}
var body: some View {
ScrollViewReader { (proxy: ScrollViewProxy) in
List{
ForEach(items, id: \.self) { Text("\($0)")
}.onDelete(perform: removeRows)
}.onChange(of: previousIndex) { (e: Equatable) in
proxy.scrollTo(previousIndex!-4, anchor: .top)
//proxy.scrollTo(0, anchor: .top) // will display 1st cell
}
}
}
}
This can now be simplified with all new ScrollViewProxy in Xcode 12, like so:
struct ContentView: View {
let itemCount: Int = 100
var body: some View {
ScrollViewReader { value in
VStack {
Button("Scroll to top") {
value.scrollTo(0)
}
Button("Scroll to buttom") {
value.scrollTo(itemCount-1)
}
ScrollView {
LazyVStack {
ForEach(0 ..< itemCount) { i in
Text("Item \(i)")
.frame(height: 50)
.id(i)
}
}
}
}
}
}
}
MacOS 11: In case you need to scroll a list based on input outside the view hierarchy. I have followed the original scroll proxy pattern using the new scrollViewReader:
struct ScrollingHelperInjection: NSViewRepresentable {
let proxy: ScrollViewProxy
let helper: ScrollingHelper
func makeNSView(context: Context) -> NSView {
return NSView()
}
func updateNSView(_ nsView: NSView, context: Context) {
helper.catchProxy(for: proxy)
}
}
final class ScrollingHelper {
//updated for mac os v11
private var proxy: ScrollViewProxy?
func catchProxy(for proxy: ScrollViewProxy) {
self.proxy = proxy
}
func scrollTo(_ point: Int) {
if let scroller = proxy {
withAnimation() {
scroller.scrollTo(point)
}
} else {
//problem
}
}
}
Environmental object:
#Published var scrollingHelper = ScrollingHelper()
In the view: ScrollViewReader { reader in .....
Injection in the view:
.background(ScrollingHelperInjection(proxy: reader, helper: scrollingHelper)
Usage outside the view hierarchy: scrollingHelper.scrollTo(3)
As mentioned in #lachezar-todorov's answer Introspect is a nice library to access UIKit elements in SwiftUI. But be aware that the block you use for accessing UIKit elements are being called multiple times. This can really mess up your app state. In my cas CPU usage was going %100 and app was getting unresponsive. I had to use some pre conditions to avoid it.
ScrollView() {
...
}.introspectScrollView { scrollView in
if aPreCondition {
//Your scrolling logic
}
}
Another cool way is to just use namespace wrappers:
A dynamic property type that allows access to a namespace defined by the persistent identity of the object containing the property (e.g. a view).
struct ContentView: View {
#Namespace private var topID
#Namespace private var bottomID
let items = (0..<100).map { $0 }
var body: some View {
ScrollView {
ScrollViewReader { proxy in
Section {
LazyVStack {
ForEach(items.indices, id: \.self) { index in
Text("Item \(items[index])")
.foregroundColor(.black)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.green.cornerRadius(16))
}
}
} header: {
HStack {
Text("header")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(bottomID)
}
}
) {
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(topID)
} footer: {
HStack {
Text("Footer")
Spacer()
Button(action: {
withAnimation {
proxy.scrollTo(topID) }
}
) {
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
}
.padding(.vertical)
.id(bottomID)
}
.padding()
}
}
.foregroundColor(.white)
.background(.black)
}
}
Two parts:
Wrap the List (or ScrollView) with ScrollViewReader
Use the scrollViewProxy (that comes from ScrollViewReader) to scroll to an id of an element in the List. You can seemingly use EmptyView().
The example below uses a notification for simplicity (use a function if you can instead!).
ScrollViewReader { scrollViewProxy in
List {
EmptyView().id("top")
}
.onReceive(NotificationCenter.default.publisher(for: .ScrollToTop)) { _ in
// when using an anchor of `.top`, it failed to go all the way to the top
// so here we add an extra -50 so it goes to the top
scrollViewProxy.scrollTo("top", anchor: UnitPoint(x: 0, y: -50))
}
}
extension Notification.Name {
static let ScrollToTop = Notification.Name("ScrollToTop")
}
NotificationCenter.default.post(name: .ScrollToTop, object: nil)