36

Is there a way to change the default scrolling animation of the ScrollView by using ScrollViewReader?

Problem

I have tried different things but the animation remained as the default one.

withAnimation(.easeInOut(duration: 60)) { // <-- Not working (changes nothing)
    proxy.scrollTo(50, anchor: .center)
}

As you can see here: (Obviously this is faster than a 1 minute animation)

My demo code

struct ContentView: View {
    
    var body: some View {
        ScrollView {
            ScrollViewReader { proxy in
                Button("Scroll to") {
                    withAnimation(.easeInOut(duration: 60)) {
                        proxy.scrollTo(50, anchor: .center)
                    }
                }
                
                ForEach(0..<100) { i in
                    Rectangle()
                        .frame(width: 200, height: 100)
                        .foregroundColor(.green)
                        .overlay(Text("\(i)").foregroundColor(.white).id(i))
                }
                .frame(maxWidth: .infinity)
            }
        }
    }
}

Maybe that's just not possible yet?

Thanks!

9
  • 5
    Looks like bug. Commented Jun 23, 2020 at 13:33
  • Have you tried running code in simulator? It was working fine for me in simulator, but not in live Previews. Commented Jul 2, 2020 at 13:15
  • Did you see the animation running for 10 seconds? It's not working for me using the simulator. I have tried again just now. Commented Jul 2, 2020 at 14:52
  • 2
    I tried too. Did not work for me but the code seems fine Commented Jul 3, 2020 at 17:27
  • 2
    I'm seeing it still not working in Xcode 13 beta 2 on Simulator. Commented Jul 11, 2021 at 13:28

3 Answers 3

13
+50

I had the same problem sometime ago, and I found this code in GitHub:

Scrollable SwiftUI Wrapper

Create a custom UIScrollView in a Swift file with this code:

import SwiftUI

struct ScrollableView<Content: View>: UIViewControllerRepresentable, Equatable {

    // MARK: - Coordinator
    final class Coordinator: NSObject, UIScrollViewDelegate {
        
        // MARK: - Properties
        private let scrollView: UIScrollView
        var offset: Binding<CGPoint>

        // MARK: - Init
        init(_ scrollView: UIScrollView, offset: Binding<CGPoint>) {
            self.scrollView          = scrollView
            self.offset              = offset
            super.init()
            self.scrollView.delegate = self
        }
        
        // MARK: - UIScrollViewDelegate
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            DispatchQueue.main.async {
                self.offset.wrappedValue = scrollView.contentOffset
            }
        }
    }
    
    // MARK: - Type
    typealias UIViewControllerType = UIScrollViewController<Content>
    
    // MARK: - Properties
    var offset: Binding<CGPoint>
    var animationDuration: TimeInterval
    var showsScrollIndicator: Bool
    var axis: Axis
    var content: () -> Content
    var onScale: ((CGFloat)->Void)?
    var disableScroll: Bool
    var forceRefresh: Bool
    var stopScrolling: Binding<Bool>
    private let scrollViewController: UIViewControllerType

    // MARK: - Init
    init(_ offset: Binding<CGPoint>, animationDuration: TimeInterval, showsScrollIndicator: Bool = true, axis: Axis = .vertical, onScale: ((CGFloat)->Void)? = nil, disableScroll: Bool = false, forceRefresh: Bool = false, stopScrolling: Binding<Bool> = .constant(false),  @ViewBuilder content: @escaping () -> Content) {
        self.offset               = offset
        self.onScale              = onScale
        self.animationDuration    = animationDuration
        self.content              = content
        self.showsScrollIndicator = showsScrollIndicator
        self.axis                 = axis
        self.disableScroll        = disableScroll
        self.forceRefresh         = forceRefresh
        self.stopScrolling        = stopScrolling
        self.scrollViewController = UIScrollViewController(rootView: self.content(), offset: self.offset, axis: self.axis, onScale: self.onScale)
    }
    
    // MARK: - Updates
    func makeUIViewController(context: UIViewControllerRepresentableContext<Self>) -> UIViewControllerType {
        self.scrollViewController
    }

    func updateUIViewController(_ viewController: UIViewControllerType, context: UIViewControllerRepresentableContext<Self>) {
        
        viewController.scrollView.showsVerticalScrollIndicator   = self.showsScrollIndicator
        viewController.scrollView.showsHorizontalScrollIndicator = self.showsScrollIndicator
        viewController.updateContent(self.content)

        let duration: TimeInterval                = self.duration(viewController)
        let newValue: CGPoint                     = self.offset.wrappedValue
        viewController.scrollView.isScrollEnabled = !self.disableScroll
        
        if self.stopScrolling.wrappedValue {
            viewController.scrollView.setContentOffset(viewController.scrollView.contentOffset, animated:false)
            return
        }
        
        guard duration != .zero else {
            viewController.scrollView.contentOffset = newValue
            return
        }
        
        UIView.animate(withDuration: duration, delay: 0, options: [.allowUserInteraction, .curveEaseInOut, .beginFromCurrentState], animations: {
            viewController.scrollView.contentOffset = newValue
        }, completion: nil)
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self.scrollViewController.scrollView, offset: self.offset)
    }
    
    //Calcaulte max offset
    private func newContentOffset(_ viewController: UIViewControllerType, newValue: CGPoint) -> CGPoint {
        
        let maxOffsetViewFrame: CGRect = viewController.view.frame
        let maxOffsetFrame: CGRect     = viewController.hostingController.view.frame
        let maxOffsetX: CGFloat        = maxOffsetFrame.maxX - maxOffsetViewFrame.maxX
        let maxOffsetY: CGFloat        = maxOffsetFrame.maxY - maxOffsetViewFrame.maxY
        
        return CGPoint(x: min(newValue.x, maxOffsetX), y: min(newValue.y, maxOffsetY))
    }
    
    //Calculate animation speed
    private func duration(_ viewController: UIViewControllerType) -> TimeInterval {
        
        var diff: CGFloat = 0
        
        switch axis {
            case .horizontal:
                diff = abs(viewController.scrollView.contentOffset.x - self.offset.wrappedValue.x)
            default:
                diff = abs(viewController.scrollView.contentOffset.y - self.offset.wrappedValue.y)
        }
        
        if diff == 0 {
            return .zero
        }
        
        let percentageMoved = diff / UIScreen.main.bounds.height
        
        return self.animationDuration * min(max(TimeInterval(percentageMoved), 0.25), 1)
    }
    
    // MARK: - Equatable
    static func == (lhs: ScrollableView, rhs: ScrollableView) -> Bool {
        return !lhs.forceRefresh && lhs.forceRefresh == rhs.forceRefresh
    }
}

final class UIScrollViewController<Content: View> : UIViewController, ObservableObject {

    // MARK: - Properties
    var offset: Binding<CGPoint>
    var onScale: ((CGFloat)->Void)?
    let hostingController: UIHostingController<Content>
    private let axis: Axis
    lazy var scrollView: UIScrollView = {
        
        let scrollView                                       = UIScrollView()
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.canCancelContentTouches                   = true
        scrollView.delaysContentTouches                      = true
        scrollView.scrollsToTop                              = false
        scrollView.backgroundColor                           = .clear
        
        if self.onScale != nil {
            scrollView.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(self.onGesture)))
        }
        
        return scrollView
    }()
    
    @objc func onGesture(gesture: UIPinchGestureRecognizer) {
        self.onScale?(gesture.scale)
    }

    // MARK: - Init
    init(rootView: Content, offset: Binding<CGPoint>, axis: Axis, onScale: ((CGFloat)->Void)?) {
        self.offset                                 = offset
        self.hostingController                      = UIHostingController<Content>(rootView: rootView)
        self.hostingController.view.backgroundColor = .clear
        self.axis                                   = axis
        self.onScale                                = onScale
        super.init(nibName: nil, bundle: nil)
    }
    
    // MARK: - Update
    func updateContent(_ content: () -> Content) {
        
        self.hostingController.rootView = content()
        self.scrollView.addSubview(self.hostingController.view)
        
        var contentSize: CGSize = self.hostingController.view.intrinsicContentSize
        
        switch axis {
            case .vertical:
                contentSize.width = self.scrollView.frame.width
            case .horizontal:
                contentSize.height = self.scrollView.frame.height
        }
        
        self.hostingController.view.frame.size = contentSize
        self.scrollView.contentSize            = contentSize
        self.view.updateConstraintsIfNeeded()
        self.view.layoutIfNeeded() 
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.addSubview(self.scrollView)
        self.createConstraints()
        self.view.setNeedsUpdateConstraints()
        self.view.updateConstraintsIfNeeded()
        self.view.layoutIfNeeded()
    }
    
    // MARK: - Constraints
    fileprivate func createConstraints() {
        NSLayoutConstraint.activate([
            self.scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            self.scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            self.scrollView.topAnchor.constraint(equalTo: self.view.topAnchor),
            self.scrollView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
        ])
    }
}

Then in your code snippet you can use it like this:

import SwiftUI

struct ContentView: View {
    @State private var contentOffset: CGPoint = .zero
    var body: some View {
        ScrollableView(self.$contentOffset, animationDuration: 5.0) {
            VStack {
                Button("Scroll to") {
                    self.contentOffset = CGPoint(x: 0, y: (100 * 50))
                }
                ForEach(0..<100) { i in
                    Rectangle()
                        .frame(width: 200, height: 100)
                        .foregroundColor(.green)
                        .overlay(Text("\(i)").foregroundColor(.white).id(i))
                }
                .frame(maxWidth: .infinity)
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

You can set the animation duration, I have set it to 5 seconds, then calculate the offset you want to scroll depending in the row height, I set it to 100 * 50 cells.

When you tap in the button the view will scroll to index 50 in 5 seconds.

Sign up to request clarification or add additional context in comments.

5 Comments

Thanks, this is unfortunately unsuitable for my app since I have a somewhat complex structure. But I'd love to give you the points ;)
Hi @Mofawaw thanks a lot, if you want to share the structure of the app it is possible we can find the way to find a solution :)
Thumbs up bro... This is really great
The problem with this is it's not using the much simpler ScrollViewReader / scrollTo method that is much simpler and the way we should be doing it now. Doesn't solve the original problem. I really wish people wouldn't regard fixing EXACT PROBLEMS as ... I don't remember but basically people criticizing the practice of fixing the current implementation rather than giving other options for the desired behavior. It's cricizied as a Y problem or something. can't remember. but sometimes when the way we're doing it is the correct method, we don't want other methods.
but this just adds to my criticism of SwiftUI. it should not be so difficult to do basic things because it seems Apple didn't provide adequate documentation on how to do the most basic things that it didn't happen to think about. This happens often in Apple's OS as well. As long as it's something that's on everybody's mind it's implemented perfectly, if it's on the outskirts of what everyone thinks of, it's highly likely it won't be implemented the way it should be.
12

I did find a way by using the Lib Introspect (not using private & undocumented API).

scrollView.introspectScrollView { $0.setValue(5.0, forKeyPath: "contentOffsetAnimationDuration")}

You won't be able to change the anim but the duration is OK.

You still have to use that code to make the scrollView scroll :

withAnimation {
        proxy.scrollTo(editedPanel!.uuid.uuidString, anchor: .top)
}

1 Comment

This one is neat and works just fine on iOS 16. Most importantly, navigation bar behaves okay with no problems.
1

This issue still alive. I couldn't find any solution on SwiftUI but there is an workaround which can change animation type and duration. This workaround work with introspect and UIKit. You can change simple math calculation or anchor padding according to your case.

Sample code

import SwiftUI
import UIKit
import SwiftUIIntrospect

class ViewModel {
var scrollView: UIScrollView?
let anchorPadding: CGFloat = 60
private let itemHeight: CGFloat = 108 //<--- item height(100) + item bottom padding(8)

func scrollTo(id: CGFloat) {
    UIView.animate(
        withDuration: 4.0, //<--- You can change animation duration.
        delay: 0.0,
        options: .curveEaseInOut, //<--- You can change animation type.
        animations: { [weak self] in
            guard let self else { return }
            scrollView?.contentOffset.y = (itemHeight * id) - anchorPadding //<--- Simple math calculation.
        }
    )
  }
}

struct ContentView: View {
private var viewModel = ViewModel()

var body: some View {
    ZStack(alignment: .top) {
        ScrollView {
            VStack(spacing: 8) {
                ForEach(0..<100) { i in
                    Rectangle()
                        .frame(width: 200, height: 100)
                        .foregroundColor(.green)
                        .overlay(Text("\(i)").foregroundColor(.white))
                }
                .frame(maxWidth: .infinity)
            }
        }
        .introspect(.scrollView, on: .iOS(.v13, .v14, .v15, .v16, .v17, .v18)) { scrollView in
            viewModel.scrollView = scrollView  //<--- Set current scroll view reference.
        }
        
        Rectangle()
            .fill(.red)
            .frame(maxWidth: .infinity)
            .frame(height: 1)
            .padding(.top, viewModel.anchorPadding)
            .ignoresSafeArea()
            .overlay(alignment: .topTrailing) {
                Button("Scroll To") {
                    viewModel.scrollTo(id: 50) //<--- Scroll to item which id is 50 or what you want.
                }
                .padding(.trailing, 8)
            }
    }
  }
}

Sample code output

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.