Swiftui: How to Set Position of Scrollview to Top Left? (Both Scrolls Is Enabled)

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:

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:

  1. I just made a basic ContentView to show the two views, since I wasn't sure how they're laid out for you
  2. ContentView owns the SelectedApplicationState (which was your selectionApplication 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.
  3. SelectedApplicationState now holds the applications array, since it was being duplicated across views anyway
  4. On selection in View1, selectedApplication in the ObservableObject is set, triggering onReceive in View2
  5. There, the ScrollViewReader is told to scroll to the item with the id stored in selectedApplication, which is passed to the onReceive closure as app

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

demo

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

demo2

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

demo

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.

  1. Rotate the outermost view 180 .rotationEffect(.radians(.pi))
  2. 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



Leave a reply



Submit