Preserving Scroll Position and Cursor State During Navigation Transitions

Preserving Scroll Position and Cursor State During Navigation Transitions

posted 9 min read

Have you ever noticed the difference between a simply good app and one that feels "native," polished, and premium? Often it's not about design or animations, but about small details we take for granted until they break. One such critical detail is an app's ability to remember where you left off. You're reading a long article, minimize the app to respond to a message, come back a minute later, and boom - you're thrown back to the very beginning of the text. Annoying, right? Or you're writing a note, switch to the browser, come back, and the keyboard is hidden with the cursor lost. In this article, we'll break down how to technically implement scroll position and cursor state preservation in iOS apps properly. We'll go beyond banal advice and examine real scenarios with navigation, tabs, and asynchronous data loading, so your users never feel lost.


Why Losing Context Hurts

Let's be honest: users don't know terms like "controller lifecycle" or "memory unloading." But they can absolutely feel when an app behaves stupidly. Losing scroll position or cursor during navigation is exactly that kind of stupid behavior. It destroys the interaction flow.

Imagine you're working in a code editor on an iPad. You scroll a file to line 500, place the cursor in the middle of a complex function, then open the side menu to check the project structure. You close the menu - and bam! - you're back at line one. At this moment, the app transforms from a professional tool into a toy.

Another classic example is news feeds or product catalogs. The user has scrolled down through a hundred items, drilled into a product detail screen, tapped Back, and their list reloaded, throwing them back to the top. This isn't just inconvenient, it's wasting the user's time. In a world with cutthroat competition for attention, such UX mistakes are unforgivable. An app that doesn't remember my state feels like a cheap web wrapper, even if it's written in pure Swift.

The Foundation: State Restoration in iOS

Apple has long provided us with a powerful mechanism to solve these problems - UI State Restoration. But in my observations, many developers ignore it, preferring to reinvent the wheel by saving data in UserDefaults or singletons. I was guilty of this myself for a long time, until I understood how the system works under the hood.

The built-in mechanism is good because it's systemic. It triggers not only when you push a controller onto the navigation stack, but also when the system kills your app in the background due to memory pressure, and then the user relaunches it.

The cornerstone of the entire mechanism is the restorationIdentifier property.

// In viewDidLoad
self.restorationIdentifier = "MyDetailViewController"

If a controller (or any UIView) doesn't have this identifier set, the system won't even attempt to save its state. This is your entry ticket. Once the identifier is set, two methods from the UIStateRestoring protocol come into play, which every UIViewController already has:

override func encodeRestorableState(with coder: NSCoder) {
    super.encodeRestorableState(with: coder)
    // Here we encode what we want to save
    coder.encode(someImportantData, forKey: "dataKey")
}

override func decodeRestorableState(with coder: NSCoder) {
    super.decodeRestorableState(with: coder)
    // And here we retrieve it back
    if let data = coder.decodeObject(forKey: "dataKey") as? DataToRestore {
        self.someImportantData = data
    }
}

Sounds simple, but the devil is in the implementation details. State Restoration doesn't save your model data (that's what Core Data or files are for). It saves interface state, which helps display that data exactly as it was before the user left.

Saving Scroll Position (ScrollView Offset)

The most common case is UITableView or UICollectionView. When the user leaves the screen (for example, pushes the next controller), we want to remember the contentOffset.

Many try to save the offset in viewWillDisappear. This is a working strategy for simple transitions within the navigation stack, when the controller stays in memory. But if we're talking about full-fledged State Restoration (with app termination), it's better to use specialized methods.

Let's say we have a UITableViewController. Fortunately, UITableView and UICollectionView already know how to save a lot themselves, if they (and their cells!) have restorationIdentifier set. But sometimes we need more control, especially if the scroll view is complex.

I prefer an explicit approach. Let's create a structure to store our screen's state:

struct ListViewState: Codable {
    let contentOffsetY: CGFloat
    // Can add selected cell ID, etc.
}

Now in the controller:

class MyListViewController: UIViewController, UITableViewDelegate {
    
    @IBOutlet weak var tableView: UITableView!
    private let stateKey = "ListViewControllerState"

    override func viewDidLoad() {
        super.viewDidLoad()
        // Critical for the mechanism to work
        self.restorationIdentifier = "MyListVC"
    }

    // MARK: - State Restoration

    override func encodeRestorableState(with coder: NSCoder) {
        super.encodeRestorableState(with: coder)
        
        // Save current offset
        let state = ListViewState(contentOffsetY: tableView.contentOffset.y)
        do {
            let data = try JSONEncoder().encode(state)
            coder.encode(data, forKey: stateKey)
        } catch {
            print("Failed to encode state: \(error)")
        }
    }

    override func decodeRestorableState(with coder: NSCoder) {
        super.decodeRestorableState(with: coder)
        
        guard let data = coder.decodeObject(forKey: stateKey) as? Data,
              let state = try? JSONDecoder().decode(ListViewState.self, from: data) else {
            return
        }
        
        // HERE'S THE MAIN GOTCHA
        // You can't just set contentOffset right now.
        // The table data might not be loaded yet.
        self.restoreScrollPosition(to: state.contentOffsetY)
    }
    
    private var pendingScrollOffset: CGFloat?

    private func restoreScrollPosition(to offset: CGFloat) {
        // Save the offset "for later"
        self.pendingScrollOffset = offset
    }
    
    // Call this method when data is definitely loaded and table has redrawn
    func dataDidFinishLoading() {
        tableView.reloadData()
        
        if let offset = pendingScrollOffset {
            // Sometimes need to give the Layout engine time to work
            DispatchQueue.main.async {
                self.tableView.setContentOffset(CGPoint(x: 0, y: offset), animated: false)
                self.pendingScrollOffset = nil
            }
        }
    }
}

Notice the pendingScrollOffset. This is a key moment when working with async data. If you try to restore contentOffset in decodeRestorableState when your table has 0 rows, the scroll will simply reset to 0. You must apply the saved offset only after the data is received and reloadData() has executed.

Hunting for the Cursor in UITextView

With text editors, things get even more interesting. Here we need to save not only scroll, but also cursor position (or text selection). This is handled by the selectedRange property of UITextView.

The problem is that cursor position is inextricably linked to keyboard focus. If during state restoration we simply set selectedRange but don't make the text field active (becomeFirstResponder), the user will see the text, possibly even scrolled to the right place, but without the cursor and keyboard. They'll have to tap again. This isn't the "seamless" experience we're aiming for.

Here's how I typically approach solving this in an editor controller:

class EditorViewController: UIViewController {
    
    @IBOutlet weak var textView: UITextView!
    private let selectionKey = "EditorSelectionRange"
    private let textKey = "EditorTextContent" // If text isn't saved in model

    override func viewDidLoad() {
        super.viewDidLoad()
        self.restorationIdentifier = "EditorVC"
        // Important: the textView itself also needs an ID if we want
        // the system to help us with scroll
        self.textView.restorationIdentifier = "EditorTextView"
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // If we restored state and should show keyboard
        if shouldRestoreFocus {
            textView.becomeFirstResponder()
            shouldRestoreFocus = false
        }
    }

    // MARK: - State Restoration

    override func encodeRestorableState(with coder: NSCoder) {
        super.encodeRestorableState(with: coder)
        
        // Save range. NSRange encodes perfectly.
        let range = textView.selectedRange
        if range.location != NSNotFound {
             coder.encode(NSValue(range: range), forKey: selectionKey)
        }
        
        // Save whether this field had focus
        coder.encode(textView.isFirstResponder, forKey: "wasFirstResponder")
    }

    private var shouldRestoreFocus = false

    override func decodeRestorableState(with coder: NSCoder) {
        super.decodeRestorableState(with: coder)
        
        // Restore text (if needed)
        // textView.text = ...

        // Restore range
        if let value = coder.decodeObject(forKey: selectionKey) as? NSValue {
            let range = value.rangeValue
            // Critical moment: need to ensure range is valid for current text
            if range.upperBound <= textView.text.count {
                textView.selectedRange = range
                
                // Often need to force scroll to cursor,
                // as automatic scroll might not work during restoration
                textView.scrollRangeToVisible(range)
            }
        }
        
        // Remember if we need to restore focus
        shouldRestoreFocus = coder.decodeBool(forKey: "wasFirstResponder")
    }
}

I deliberately moved becomeFirstResponder() to viewDidAppear. Calling it in decodeRestorableState or viewDidLoad is often premature, since the view hierarchy isn't fully ready yet, and the system might ignore the keyboard request. Plus, keyboard appearance changes the UITextView insets, which can affect scroll. By doing this in viewDidAppear, we let the interface "settle."

Tabs, Modals, and App Backgrounding: A Unified Strategy

Developers often implement different mechanisms for different navigation types. For UINavigationController they use viewWillDisappear to save offset. For tab bar - UITabBarControllerDelegate methods. For app backgrounding - they subscribe to UIApplication.didEnterBackgroundNotification notifications.

This is a path to code duplication and bugs. I'm a proponent of a unified strategy.

The State Restoration mechanism described above (encodeRestorableState/decodeRestorableState) works perfectly for the "kill and resurrect" app scenario.

But what about simple navigation within a live app? Switching tabs or push/pop controllers. Here State Restoration in its pure form doesn't quite fit, since it's about "cold restart."

For "hot" navigation I prefer using lifecycle methods viewWillDisappear and viewWillAppear as the most reliable triggers.

  • Leaving the screen (viewWillDisappear): This is the perfect place to save current "ephemeral" state (scroll, focus) to a local controller variable or associated ViewModel. Doesn't matter why the screen is disappearing - new one being pushed, tab switching, or modal presenting. This method will be called.
  • Returning to screen (viewWillAppear / viewDidAppear): Here we check if there's saved state and apply it.

Example for "hot" saving in a list:

class HotListViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!
    
    // Store offset right in controller for quick transitions
    private var lastScrollOffset: CGPoint?

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // Leaving? Remember where we were.
        lastScrollOffset = tableView.contentOffset
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        // Returned? If there's something to restore, and data hasn't changed critically.
        if let offset = lastScrollOffset {
            // Important: check table isn't empty, otherwise crash or glitch
            if tableView.contentSize.height > offset.y {
                 tableView.setContentOffset(offset, animated: false)
            }
        }
    }
}

This approach works great when switching tabs in UITabBarController. Controllers in tabs usually aren't unloaded from memory, so the local lastScrollOffset variable will hold the value indefinitely, as long as the app session is alive.

If you're using custom navigation where controllers are recreated, you'll need to save this "hot" state somewhere external - for example, in a navigation service or global state storage tied to screen ID.

Testing: Breaking What We Built

The biggest mistake when implementing these features is improper testing. Simply minimizing the app with the Home button and reopening it is not a State Restoration test. In this case, the app simply "sleeps" in RAM, and when awakened, all views and controllers remain in place untouched. No decodeRestorableState is called.

To test for real, you need to simulate system actions:

  1. Simulating memory pressure: In iOS Simulator, select Debug -> Simulate Memory Warning from the menu. If your controller is deep in the navigation stack or in an inactive tab, and it's "heavy," the system might unload it (call didReceiveMemoryWarning, and then possibly free its view). When returning to it, the restoration mechanism should trigger.
  2. App termination in background: This is the main test.
    • Launch the app from Xcode.
    • Minimize it (go to Home Screen).
    • In Xcode, press the "Stop" button (square). This kills the process, but as if the system did it in the background (saving state), not like a crash.
    • Launch the app again from the icon on simulator/device (not the Run button in Xcode, otherwise the saved state will be wiped).
    • If you did everything right, the app should launch not from scratch, but restore the controller hierarchy and their state, including scroll and cursors.

Common Pitfalls: Asynchronicity Is My Enemy

I've touched on this before, but I'll repeat it, as this is the most frequent cause of bugs.

The Problem: You saved contentOffset.y = 2000. When the app launches, decodeRestorableState fires, and you dutifully try to do tableView.setContentOffset(CGPoint(0, 2000)). But at this moment, your ViewModel has just started an asynchronous network request for data. The table is empty. Its contentSize is zero. Setting the offset is either ignored or gets reset to zero at the first data reload.

The Solution: Never restore scroll position (or text cursor) until the content is ready.

  1. In decodeRestorableState, only decode the parameters and save them to temporary properties of the controller.
  2. Wait for the data loading to complete.
  3. Call reloadData().
  4. Only after this (sometimes it's useful to wrap in DispatchQueue.main.async to let the layout cycle pass) apply the saved parameters.

If you're using reactive frameworks like RxSwift or Combine, this is convenient to do in a subscription to data updates, right after new data has been passed to the diffable data source or table reload has been called.

Careful attention to state preservation is what distinguishes a mature product from an MVP. It's care for the user that they may not consciously notice, but will definitely feel its absence if you decide to cut corners on this.

1 Comment

0 votes

More Posts

React Native Quote Audit - USA

kajolshah - Mar 2

Modern Networking in iOS with URLSession and async/await – Part 2

Mark Kazakov - Dec 25, 2025

I open-sourced 5 tiny SwiftUI utilities I use in every project

Gonigon - Mar 23

Magic Beyond Hogwarts: How to Build "Expensive" UI Without Overloading the GPU

Konstantin Shkurko - Feb 7

How much does it take to “finish” an app

Petr Jahoda - Feb 5
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

6 comments
3 comments
2 comments

Contribute meaningful comments to climb the leaderboard and earn badges!