How to scroll List programmatically in SwiftUI?
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
}
}
SwiftUI: how to detect when List is scrolled?
I recommend to use simultaneousGesture
modifier as in below:
List {
// ... your list items
}
.simultaneousGesture(DragGesture().onChanged({ _ in
// if keyboard is opened then hide it
}))
Update: verified with Xcode 13.3 / iOS 15.4 - for the use-case formulated by PO still works fine.
How to make a SwiftUI List scroll automatically?
Update: In iOS 14 there is now a native way to do this.
I am doing it as such
ScrollViewReader { scrollView in
ScrollView(.vertical) {
LazyVStack {
ForEach(notes, id: \.self) { note in
MessageView(note: note)
}
}
.onAppear {
scrollView.scrollTo(notes[notes.endIndex - 1])
}
}
}
For iOS 13 and below you can try:
I found that flipping the views seemed to work quite nicely for me. This starts the ScrollView at the bottom and when adding new data to it automatically scrolls the view down.
- Rotate the outermost view 180
.rotationEffect(.radians(.pi))
- Flip it across the vertical plane
.scaleEffect(x: -1, y: 1, anchor: .center)
You will have to do this to your inner views as well, as now they will all be rotated and flipped. To flip them back do the same thing above.
If you need this many places it might be worth having a custom view for this.
You can try something like the following:
List(chatController.messages, id: \.self) { message in
MessageView(message.text, message.isMe)
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
Here's a View extension to flip it
extension View {
public func flip() -> some View {
return self
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
}
SwiftUI - Detect when ScrollView has finished scrolling?
Here is a demo of possible approach - use publisher with changed scrolled content coordinates with debounce, so event reported only after coordinates stopped changing.
Tested with Xcode 12.1 / iOS 14.1
UPDATE: verified as worked with Xcode 13.3 / iOS 15.4
Note: you can play with debounce period to tune it for your needs.
import Combine
struct ContentView: View {
let detector: CurrentValueSubject<CGFloat, Never>
let publisher: AnyPublisher<CGFloat, Never>
init() {
let detector = CurrentValueSubject<CGFloat, Never>(0)
self.publisher = detector
.debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
.dropFirst()
.eraseToAnyPublisher()
self.detector = detector
}
var body: some View {
ScrollView {
VStack(spacing: 20) {
ForEach(0...100, id: \.self) { i in
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(.green)
.overlay(Text("\(i)"))
}
}
.frame(maxWidth: .infinity)
.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { detector.send($0) }
}.coordinateSpace(name: "scroll")
.onReceive(publisher) {
print("Stopped on: \($0)")
}
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
In SwiftUI, where are the control events, i.e. scrollViewDidScroll to detect the bottom of list data
You can to check that the latest element is appeared inside onAppear.
struct ContentView: View {
@State var items = Array(1...30)
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
.onAppear {
if let last == self.items.last {
print("last item")
self.items += last+1...last+30
}
}
}
}
}
}
SwiftUI: How to listen mouse wheel scroll event in horizontal scrollView
You can simply listen to the app's currentEvent
and skip a snooping NSViewRep when:
- you already have a hover/focus boolean for the view
- an NSTrackingArea isn't necessary to restrict a response (e.g., only one ScrollView in a transient popover that a user would expect to interact with all the time)
Below is an example from a transient popover in a macOS SwiftUI App of mine. A horizontal ScrollView presents options, which can be "scroll selected" by vertical scrolling slowly or quickly. You could plug ScrollViewProxy in for the same idea.
In a test implementation, using .onReceive() {} would recalculate the view and a single mouse scroll would trigger repeatedly. Storing the subscription in an @State var lets it work as desired.
//horizontal scroll view
.onAppear { trackScrollWheel() }
func trackScrollWheel() {
NSApp.publisher(for: \.currentEvent)
.filter { event in event?.type == .scrollWheel }
.throttle(for: .milliseconds(200),
scheduler: DispatchQueue.main,
latest: true)
.sink { [weak vm] event in
vm?.goBackOrForwardBy(delta: Int(event?.deltaY ?? 0))
}
.store(in: &subs)
}
@State var subs = Set<AnyCancellable>() // Cancel onDisappear
Related Topics
Uiswipegesturerecognizer Doesn't Recognize Swipe Gesture Initiated Outside the View
Swift How to Assign a String to a Uitextfield
Realmswift + Multiple Predicate
Convert to Latest Swift Syntax' Breaks the Build Even When There Are No Changes
How to Convert Data of Int16 Audio Samples to Array of Float Audio Samples
How to Change Audio Pitch During Playback? (Swift 4)
Deleting a Camera Roll Asset Using Photos Framework
How to Suppress Warnings in Swift 3
How Do We Implement Wait/Notify in Swift
How to I Access Argv and Argc in Swift
Uicollectionview Autosize and Dynamic Number of Rows
Gcd Pattern for Chaining Async Operations While Piping the Results
Is a Static Boolean a Reference Type in Swift
How to Send Data from iPhone to Watchkit in Os2 in Swift
Swift - How to Get Text Formatting in a Text Editor Like in the Notes App? Swiftui
How to Use Key-Value Observing with Smart Keypaths in Swift 4
Swift Client and Root Ssl Certificate Authentication
Swift Convert Decimal Coordinate into Degrees, Minutes, Seconds, Direction