SwiftUI: How to set position of ScrollView to Top Left? (both scrolls is enabled)
Update: Xcode 13.4 / macOS 12.4
The issue is still there, but now the solution is simpler using ScrollViewReader:
struct TestTwoAxisScrollView: View {
var body: some View {
ScrollViewReader { sp in
ScrollView([.horizontal, .vertical]) {
VStack {
ForEach(0..<100) { _ in
self.row()
}
}
.border(Color.green)
.id("root")
}
.border(Color.gray)
.padding()
.onAppear {
sp.scrollTo("root", anchor: .topLeading)
}
}
}
func row() -> some View {
Text(test)
.border(Color.red) // uncomment to see border
}
}
Original
Here is possible approach. Tested with Xcode 11.2 / macOS 10.15.3
Demo:
Code (complete testable module, borders are added for better visibility of each component):
import SwiftUI
let test = """
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12118" systemVersion="16A323" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
"""
struct ScrollViewHelper: NSViewRepresentable {
func makeNSView(context: NSViewRepresentableContext<ScrollViewHelper>) -> NSView {
let view = NSView(frame: .zero)
DispatchQueue.main.async { // << must be async, so view got into view hierarchy
view.enclosingScrollView?.contentView.scroll(to: .zero)
view.enclosingScrollView?.reflectScrolledClipView(view.enclosingScrollView!.contentView)
}
return view
}
func updateNSView(_ nsView: NSView, context: NSViewRepresentableContext<ScrollViewHelper>) {
}
}
struct TestTwoAxisScrollView: View {
var body: some View {
ScrollView([.horizontal, .vertical]) {
VStack {
ForEach(0..<100) { _ in
self.row()
}
}
.background(ScrollViewHelper()) // << active part !!
.border(Color.green) // uncomment to see border
}
.border(Color.gray)
.padding()
}
func row() -> some View {
Text(test)
.border(Color.red) // uncomment to see border
}
}
struct TestTwoAxisScrollView_Previews: PreviewProvider {
static var previews: some View {
TestTwoAxisScrollView()
}
}
SwiftUI ScrollView Scroll to Relative Position
You can solve this in pure-SwiftUI by using a ZStack
with invisible background views:
struct ContentView: View {
var body: some View {
ScrollViewReader { reader in
ScrollView(.horizontal) {
ZStack {
HStack {
ForEach(0..<100) { i in
Spacer().id(i)
}
}
Text("Example for a long view without individual elements that you could use id on. Example for a long view without individual elements that you could use id on. Example for a long view without individual elements that you could use id on. Example for a long view without individual elements that you could use id on. Example for a long view without individual elements that you could use id on. Example for a long view without individual elements that you could use id on. Example for a long view without individual elements that you could use id on. Example for a long view without individual elements that you could use id on. Example for a long view without individual elements that you could use id on. Example for a long view without individual elements that you could use id on.")
}
}
Button("Go to 0.8") {
withAnimation {
reader.scrollTo(80)
}
}
}
}
}
Scroll to selected position in scrollview when view loads - SwiftUI
You can use ScrollViewReader
with the id
that's being applied in the ForEach
, so you don't actually need the row index number (although that, too, is possible to get, if you needed to, either by using enumerated
or searching the applications
array for the index of the item.
Here's my updated code:
struct ContentView : View {
@ObservedObject var selectedOption = SelectedApplicationState()
var body: some View {
VStack {
View1(application: selectedOption)
View2(application: selectedOption)
}
}
}
class SelectedApplicationState: ObservableObject {
@Published var selectedApplication = "application1"
var applications = ["application1", "application2", "application3", "application4", "application5", "application6", "application7", "application8", "application9", "application10"]
}
struct View1: View {
@ObservedObject var application: SelectedApplicationState
var body: some View {
VStack{
ScrollView(.horizontal){
HStack{
ForEach(application.applications, id: \.self) { item in
Button(action: {
self.application.selectedApplication = item
}) {
VStack(alignment: .center){
Text(item)
}
}
}
}
}
}
}
}
struct View2: View {
@ObservedObject var application: SelectedApplicationState
var body: some View {
HStack{
ScrollView(.horizontal, showsIndicators: false) {
ScrollViewReader{ scroll in
HStack{
ForEach(application.applications, id: \.self) { item in
Button(action: {
application.selectedApplication = item
}) {
Text(item)
.saturation(application.selectedApplication == item ? 1.0 : 0.05)
}
}
}.onReceive(application.$selectedApplication) { (app) in
withAnimation {
scroll.scrollTo(app, anchor: .leading)
}
}
}
}
}
}
}
How it works:
- I just made a basic ContentView to show the two views, since I wasn't sure how they're laid out for you
- ContentView owns the
SelectedApplicationState
(which was yourselectionApplication
ObservableObject (by the way, it is common practice to capitalize your type names -- that's why I changed the name. Plus, it was confusing to have a type and a property of that type with such a similar name) and passes it to both views. SelectedApplicationState
now holds theapplications
array, since it was being duplicated across views anyway- On selection in View1,
selectedApplication
in the ObservableObject is set, triggeringonReceive
inView2
- There, the
ScrollViewReader
is told to scroll to the item with the id stored inselectedApplication
, which is passed to theonReceive
closure asapp
In the event that these views are on separate pages, the position of View2 will still get set correctly once it is navigated to, because onReceive
will fire on first load and set it to the correct position. The only requirement is passing that instance of SelectedApplicationState
around.
Save ScrollViews position and scroll back to it later (offset to position)
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)
}
}
}
}
}
}
}
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
}
}
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)
}
}
How to detect scroll direction programmatically in SwiftUI ScrollView
You would use GeometryReader to get the global position of one in the views in the ScrollView to detect the scroll direction. The code below will print out the current midY position. Dependent on the +/- value you could display or hide other views.
struct ContentView: View {
var body: some View {
ScrollView{
GeometryReader { geometry in
Text("Top View \(geometry.frame(in: .global).midY)")
.frame(width: geometry.size.width, height: 50)
.background(Color.orange)
}
}.frame(minWidth: 0, idealWidth: 0, maxWidth: .infinity, minHeight: 0, idealHeight: 0, maxHeight: .infinity, alignment: .center)
}
}
Get UIScrollView to scroll to the top
UPDATE FOR iOS 7
[self.scrollView setContentOffset:
CGPointMake(0, -self.scrollView.contentInset.top) animated:YES];
ORIGINAL
[self.scrollView setContentOffset:CGPointZero animated:YES];
or if you want to preserve the horizontal scroll position and just reset the vertical position:
[self.scrollView setContentOffset:CGPointMake(self.scrollView.contentOffset.x, 0)
animated:YES];
Related Topics
Filteredarrayusingpredicate Does Not Exist in Swift Array
Xcode: Using Custom Fonts Inside Dynamic Framework
How to Get Rid of Array Brackets While Printing
Swift Protocol Property in Protocol - Candidate Has Non-Matching Type
Observe Progress of Data Download in Swift
How to Get Core Data Entity by It's Objectid
Swift ? Must Be Followed by a Call, Member Lookup, or Subscript
Table View Cellforrowatindexpath Warning
What's Wrong with My #If Target_Os_Simulator Code for Realm Path Definition
Problem with Gesture in Xcode 12 and iOS 14
"Message from Debugger: Unable to Attach" When Running Tests on Osx App
Swift - Kvo - Change.Newvalue and Change.Oldvalue Are Nil
Dyld: Library Not Loaded, App Requires Afnetworking 2.0.0 But Provides Version 1.0.0