Вы когда-нибудь замечали разницу между просто хорошим приложением и тем, которое ощущается «родным», надежным и дорогим? Часто дело не в дизайне или анимациях, а в мелочах, которые мы воспринимаем как должное, пока они не ломаются. Одна из таких критических мелочей - способность приложения помнить, где вы остановились. Вы читаете длинную статью, сворачиваете приложение, чтобы ответить на сообщение, возвращаетесь через минуту, а вас выбрасывает в самое начало текста. Раздражает, правда? Или пишете заметку, переключаетесь на браузер, возвращаетесь, а клавиатура скрыта и курсор потерян. В этой статье мы разберем, как технически грамотно реализовать сохранение позиции скролла и состояния курсора в iOS-приложениях. Мы уйдем дальше банальных советов и рассмотрим реальные сценарии с навигацией, табами и асинхронной загрузкой данных, чтобы ваши пользователи никогда не чувствовали себя потерянными.

Почему потеря контекста - это боль

Давайте честно: пользователи не знают терминов «жизненный цикл контроллера» или «выгрузка из памяти». Но они отлично чувствуют, когда приложение ведет себя глупо. Потеря позиции скролла или курсора при навигации - это именно глупое поведение. Оно разрушает поток взаимодействия (flow).

Представьте, что вы работаете в коде-редакторе на iPad. Вы скроллите файл на 500-ю строку, ставите курсор в середину сложной функции, а затем открываете боковое меню, чтобы глянуть структуру проекта. Закрываете меню - и бац! - вы снова на первой строке. В этот момент приложение превращается из профессионального инструмента в игрушку.

Другой классический пример - ленты новостей или каталоги товаров. Пользователь пролистал сто позиций вниз, провалился в детальный экран товара, вернулся назад по кнопке Back, и его список перезагрузился, отбросив в начало. Это не просто неудобство, это потеря времени пользователя. В мире с жесточайшей конкуренцией за внимание такие UX-ошибки непростительны. Приложение, которое не помнит мое состояние, воспринимается как дешевая веб-обертка, даже если написано на чистом Swift.

База: State Restoration в iOS

Apple давно предоставила нам мощный механизм для решения этих проблем - UI State Restoration. Но, по моим наблюдениям, многие разработчики его игнорируют, предпочитая изобретать велосипеды с сохранением данных в UserDefaults или синглтонах. Я и сам долгое время грешил этим, пока не разобрался, как система работает изнутри.

Встроенный механизм хорош тем, что он системный. Он срабатывает не только когда вы пушите контроллер в навигационный стек, но и когда система убивает ваше приложение в фоне из-за нехватки памяти, а потом пользователь его перезапускает.

Краеугольный камень всего механизма - свойство restorationIdentifier.

// Во viewDidLoad
self.restorationIdentifier = "MyDetailViewController"

Если у контроллера (или любой UIView) не задан этот идентификатор, система даже не попытается сохранить его состояние. Это входной билет. Как только идентификатор задан, в игру вступают два метода протокола UIStateRestoring, которые уже есть у каждого UIViewController:

override func encodeRestorableState(with coder: NSCoder) {    
  super.encodeRestorableState(with: coder)    
  // Здесь мы кодируем то, что хотим сохранить    
  coder.encode(someImportantData, forKey: "dataKey")}

override func decodeRestorableState(with coder: NSCoder) {    
  super.decodeRestorableState(with: coder)    
  // А здесь достаем обратно    
  if let data = coder.decodeObject(forKey: "dataKey") as? DataToRestore {  
     self.someImportantData = data    
  }
}

Звучит просто, но дьявол кроется в деталях реализации. State Restoration не сохраняет данные вашей модели (для этого есть Core Data или файлы). Он сохраняет состояние интерфейса, которое помогает отобразить эти данные так же, как было до ухода пользователя.

Сохраняем позицию скролла (ScrollView Offset)

Самый распространенный кейс - это UITableView или UICollectionView. Когда пользователь уходит с экрана (например, пушит следующий контроллер), мы хотим запомнить contentOffset.

Многие пытаются сохранять offset в viewWillDisappear. Это рабочая стратегия для простых переходов внутри навигационного стека, когда контроллер остается в памяти. Но если мы говорим о полноценном State Restoration (с убийством приложения), то лучше использовать специализированные методы.

Допустим, у нас есть UITableViewController. К счастью, UITableView и UICollectionView уже умеют многое сохранять сами, если у них (и у их ячеек!) проставлены restorationIdentifier. Но иногда нам нужно больше контроля, особенно если скроллвью сложный.

Я предпочитаю явный подход. Создадим структуру для хранения состояния нашего экрана:

Swift

struct ListViewState: Codable {    
  let contentOffsetY: CGFloat    
  // Можно добавить ID выбранной ячейки и т.д.
}

Теперь в контроллере:

class MyListViewController: UIViewController, UITableViewDelegate {        
  @IBOutlet weak var tableView: UITableView!    
  private let stateKey = "ListViewControllerState"    
  
  override func viewDidLoad() {        
    super.viewDidLoad()        
    // Критически важно для работы механизма        
    self.restorationIdentifier = "MyListVC"    
  }   
  
  // MARK: - State Restoration   
  override func encodeRestorableState(with coder: NSCoder) {        
    super.encodeRestorableState(with: coder)
    
    // Сохраняем текущий оффсет        
    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        
    }                
    // ВОТ ТУТ ГЛАВНЫЙ ПОДВОХ        
    // Нельзя просто взять и установить contentOffset прямо сейчас.        
    // Данные таблицы могут быть еще не загружены.        
    self.restoreScrollPosition(to: state.contentOffsetY)    
  }        
  private var pendingScrollOffset: CGFloat?   
  private func restoreScrollPosition(to offset: CGFloat) {        
    // Сохраняем оффсет "на будущее"        
    self.pendingScrollOffset = offset    
  }        
  // Вызываем этот метод, когда данные точно загружены и таблица перерисовалась
  func dataDidFinishLoading() {        
    tableView.reloadData()                
    if let offset = pendingScrollOffset {            
      // Иногда нужно дать Layout движку время отработать            
      DispatchQueue.main.async {                
        self.tableView.setContentOffset(CGPoint(x: 0, y: offset), animated: false) 
        self.pendingScrollOffset = nil            
      }        
    }    
  }
}

Об��атите внимание на pendingScrollOffset. Это ключевой момент при работе с асинхронными данными. Если вы попытаетесь восстановить contentOffset в decodeRestorableState, когда у вашей таблицы 0 строк, скролл просто сбросится в 0. Вы должны применить сохраненный оффсет только после того, как данные получены, и reloadData() отработал.

Охота за курсором в UITextView

С текстовыми редакторами все еще интереснее. Здесь нам нужно сохранить не только скролл, но и положение курсора (или выделения текста). За это отвечает свойство selectedRange у UITextView.

Проблема в том, что положение курсора неразрывно связано с фокусом клавиатуры. Если при восстановлении состояния мы просто зададим selectedRange, но не сделаем текстовое поле активным (becomeFirstResponder), пользователь увидит текст, возможно, даже проскролленный до нужного места, но без курсора и клавиатуры. Ему придется тапать снова. Это не тот "бесшовный" опыт, к которому мы стремимся.

Вот как я обычно подхожу к решению этой задачи в контроллере редактора:

class EditorViewController: UIViewController { 
  
  @IBOutlet weak var textView: UITextView!    
  private let selectionKey = "EditorSelectionRange"    
  private let textKey = "EditorTextContent" 
  // Если текст не сохраняется в модели   
  
  override func viewDidLoad() {        
    super.viewDidLoad()        
    self.restorationIdentifier = "EditorVC"        
    // Важно: и самому textView тоже нужен ID, если мы хотим,         
    // чтобы система помогала нам со скроллом        
    self.textView.restorationIdentifier = "EditorTextView"    
  }        
  
  override func viewDidAppear(_ animated: Bool) {        
    super.viewDidAppear(animated)        
    // Если мы восстановили состояние и должны показать клавиатуру        
    if shouldRestoreFocus {            
      textView.becomeFirstResponder()            
      shouldRestoreFocus = false        
    }    
  }
  
  // MARK: - State Restoration   
  override func encodeRestorableState(with coder: NSCoder) {        
    super.encodeRestorableState(with: coder)  
    
    // Сохраняем range. NSRange отлично кодируется.        
    let range = textView.selectedRange        
    if range.location != NSNotFound {             
      coder.encode(NSValue(range: range), forKey: selectionKey)        
    }                
    
    // Сохраняем факт, был ли фокус на этом поле        
    coder.encode(textView.isFirstResponder, forKey: "wasFirstResponder")    
  }  
  
  private var shouldRestoreFocus = false   
  
  override func decodeRestorableState(with coder: NSCoder) {        
    super.decodeRestorableState(with: coder)                
    // Восстанавливаем текст (если нужно)        
    // textView.text = ...        
    
    // Восстанавливаем range        
    if let value = coder.decodeObject(forKey: selectionKey) as? NSValue {           
      let range = value.rangeValue   
      
      // Критический момент: нужно убедиться, что range валиден для текущего текста            
      if range.upperBound <= textView.text.count {                
        textView.selectedRange = range  
        // Часто нужно принудительно проскроллить до курсора,                
        // так как автоматический скролл может не сработать при восстановлении
        textView.scrollRangeToVisible(range)            
      }        
    }                
    // Запоминаем, нужно ли вернуть фокус        
    shouldRestoreFocus = coder.decodeBool(forKey: "wasFirstResponder")    
  }
}

Я специально перенес becomeFirstResponder() в viewDidAppear. Вызов его в decodeRestorableState или viewDidLoad часто бывает преждевременным, так как иерархия вью еще не до конца готова, и система может проигнорировать запрос на показ клавиатуры. К тому же, появление клавиатуры изменяет инсеты UITextView, что может повлиять на скролл. Делая это в viewDidAppear, мы даем интерфейсу "устаканиться".

Табы, модалки и сворачивание приложения: единая стратегия

Часто разработчики реализуют разные механизмы для разных типов навигации. Для UINavigationController используют viewWillDisappear для сохранения офсета. Для таб-бара - методы делегата UITabBarControllerDelegate. Для сворачивания приложения - подписываются на нотификации UIApplication.didEnterBackgroundNotification.

Это путь к дублированию кода и багам. Я сторонник единой стратегии.

Механизм State Restoration, описанный выше (encodeRestorableState/decodeRestorableState), прекрасно работает для сценария "убийства и воскрешения" приложения.

А что насчет простой навигации внутри живого приложения? Переключение табов или пуш/поп контроллеров. Здесь State Restoration в чистом виде не совсем подходит, так как он про "холодный рестарт".

Для "горячей" навигации я предпочитаю использовать методы жизненного цикла viewWillDisappear и viewWillAppear как самые надежные триггеры.

  • Уход с экрана (viewWillDisappear): Это идеальное место, чтобы сохранить текущее "эфемеридное" состояние (скролл, фокус) в локальную переменную контроллера или в связанную с ним ViewModel. Неважно, почему экран исчезает - пушится новый, переключается таб или презентуется модалка. Этот метод будет вызван.

  • Возвращение на экран (viewWillAppear / viewDidAppear): Здесь мы проверяем, есть ли сохраненное состояние, и применяем его.

Пример для "горячего" сохранения в списке:

class HotListViewController: UIViewController {    
  @IBOutlet weak var tableView: UITableView!        
  
  // Храним оффсет прямо в контроллере для быстрых переходов    
  private var lastScrollOffset: CGPoint?   
  
  override func viewWillDisappear(_ animated: Bool) {        
    super.viewWillDisappear(animated)        
    // Уходим? Запоминаем, где были.        
    lastScrollOffset = tableView.contentOffset    
  }        
  
  override func viewWillAppear(_ animated: Bool) {        
    super.viewWillAppear(animated)   
    
    // Вернулись? Если есть что восстановить, и данные не менялись критично. 
    if let offset = lastScrollOffset {            
      // Важно: проверка, не пустая ли таблица, иначе будет крэш или глитч 
      if tableView.contentSize.height > offset.y {                 
         tableView.setContentOffset(offset, animated: false)            
      }        
    }   
  }
}

Такой подход отлично работает при переключении табов в UITabBarController. Контроллеры в табах обычно не выгружаются из памяти, поэтому локальная переменная lastScrollOffset будет хранить значение сколько угодно долго, пока жива сессия приложения.

Если же вы используете кастомную навигацию, где контроллеры пересоздаются, вам придется сохранять это "горячее" состояние куда-то вовне - например, в сервис навигации или глобальное хранилище состояний, привязанное к ID экрана.

Тестирование: ломаем то, что построили

Самая большая ошибка при внедрении этих фич - неправильное тестирование. Просто свернуть приложение кнопкой Home и развернуть обратно - это не тест State Restoration. В этом случае приложение просто "засыпает" в оперативной памяти, и при пробуждении все вью и контроллеры остаются на своих местах нетронутыми. Никакой decodeRestorableState не вызывается.

Чтобы протестировать по-настоящему, нужно симулировать действия системы:

  1. Симуляция нехватки памяти: В симуляторе iOS выберите в меню Debug -> Simulate Memory Warning. Если ваш контроллер находится глубоко в стеке навигации или в неактивном табе, и он "тяжелый", система может его выгрузить (вызвать didReceiveMemoryWarning, а затем, возможно, освободить его view). При возврате на него должен сработать механизм восстановления.

  2. Терминация приложения в фоне: Это главный тест.

    • Запустите приложение из Xcode.

    • Сверните его (перейдите на Home Screen).

    • В Xcode нажмите кнопку "Stop" (квадратик). Это убьет процесс, но так, как будто это сделала система в фоне (сохранив стейт), а не как при крэше.

    • Запустите приложение снова с иконки на симуляторе/устройстве (не кнопкой Run в Xcode, иначе сотрется сохраненный стейт).

    • Если вы все сделали правильно, приложение должно запуститься не с чистого листа, а восстановить иерархию контроллеров и их состояние, включая скролл и курсоры.

Типичные грабли: асинхронность - враг мой

Я уже касался этого, но повторюсь, так как это самая частая причина багов.

Проблема: Вы сохранили contentOffset.y = 2000. При запуске приложения срабатывает decodeRestorableState, вы честно пытаетесь сделать tableView.setContentOffset(CGPoint(0, 2000)). Но в этот момент ваша ViewModel только начала асинхронный запрос к сети за данными. Таблица пуста. Ее contentSize равен нулю. Установка оффсета либо игнорируется, либо сбрасывается в ноль при первой же перезагрузке данных.

Решение: Никогда не восстанавливайте скролл (и курсор в тексте), пока контент не готов.

  1. В decodeRestorableState только декодируйте параметры и сохраняйте их во временные свойства контроллера.

  2. Дождитесь окончания загрузки данных.

  3. Вызовите reloadData().

  4. Только после этого (иногда полезно обернуть в DispatchQueue.main.async, чтобы дать пройти циклу layout) применяйте сохраненные параметры.

Если вы используете реактивные фреймворки вроде RxSwift или Combine, это удобно делать в подписке на обновление данных, сразу после того, как новые данные были переданы в diffable data source или вызвана перезагрузка таблицы.

Внимательное отношение к сохранению состояния - это то, что отличает зрелый продукт от MVP. Это забота о пользователе, которую он, возможно, не осознает, но точно почувствует её отсутствие, если вы решите сэкономить на этом время.