Мобильная разработка

iOS 26: UISlider .valueChanged — allTouches nil

В iOS 26 событие .valueChanged UISlider приходит пустым, allTouches == nil. Причины проблемы, рабочие обходы: touch-события, подкласс UISlider, тестирование. Обновление ios 26 и фиксы для разработчиков.

В iOS 26 событие .valueChanged UISlider всегда пустое, а allTouches всегда равно nil, невозможно определить конец перетаскивания слайдера

У меня есть приложение, которое работало нормально до iOS 26. В моем приложении я использую событие .valueChanged UISlider для получения фазы и выполнения различных действий в зависимости от того, является ли она .began, .moved или .ended.

Это распространенное решение для определения фаз взаимодействия со слайдером. Ниже приведен мой простой пример:

swift
import UIKit
import SnapKit

class ViewController: UIViewController {
 
 let label = UILabel()
 let slider = UISlider()

 override func viewDidLoad() {
 super.viewDidLoad()
 
 let stackView = UIStackView(arrangedSubviews: [label, slider])
 stackView.axis = .vertical
 stackView.spacing = 20
 view.addSubview(stackView)
 
 stackView.snp.makeConstraints { make in
 make.centerY.equalToSuperview()
 make.horizontalEdges.equalToSuperview().inset(30)
 }
 
 label.textAlignment = .center
 
 slider.isContinuous = true
 slider.minimumValue = 0
 slider.maximumValue = 100
 slider.addTarget(self, action: #selector(sliderValueChanged(sender:event:)), for: .valueChanged)
 
 }

 @objc func sliderValueChanged(sender: UISlider, event: UIEvent) {
 print("Слайдер: (slider.value), (event)")
 label.text = "Значение: (sender.value)"
 
 switch event.allTouches?.first?.phase {
 case .began:
 print("начато")
 case .moved:
 print("перемещено")
 case .ended:
 print("завершено")
 case .cancelled:
 print("отменено")
 default:
 break
 }
 }

}

Ранее это работало нормально. Но в iOS 26 событие, передаваемое в мою функцию sliderValueChanged, проблематично. Оно не равно nil, но также не является корректным событием. Например, операция print просто выводит пустой текст для события. А установка точки останова показывает его как пустой объект события.

Из-за этого в iOS 26 event.allTouches всегда равно nil. Поэтому я не могу определить, какая фаза события.

Это ошибка? Как можно обойти эту проблему?

Я обнаружил, что если я переключаюсь с .valueChanged на .allEvents, то получаю событие для фаз began и moved. Однако я не получаю фазы ended или cancelled.

Как получить и эти фазы?

В iOS 26 действительно наблюдаются изменения: в обработчике UISlider для события .valueChanged объект UIEvent часто приходит без прикосновений — event.allTouches == nil, и на его основе нельзя надёжно определять фазы .began/.moved/.ended. Рабочие обходы — не полагаться на event.allTouches в .valueChanged, а использовать явные UIControl.Event (.touchDown, .touchUpInside/.touchUpOutside/.touchCancel, .touchDragInside) или подклассировать UISlider и переопределить beginTracking/continueTracking/endTracking. Также стоит воспроизвести баг на нескольких устройствах и, при подтверждении, отправить отчёт в Apple (Feedback Assistant).


Содержание


Причина: iOS 26 и UISlider .valueChanged — почему event.allTouches == nil

Коротко — поведение выглядит как изменение в том, как iOS 26 формирует/передаёт UIEvent в action-методы для .valueChanged. Пара причин, которые надо учитывать одновременно:

  • .valueChanged — это контрольное событие UIControl; оно может вызываться не только в ответ на физическое прикосновение (touch), но и программно или через Accessibility/клавиатуру. В таких случаях event.allTouches будет пустым.
  • В iOS 26, судя по репортам, система иногда передаёт UIEvent без ссылок на touches даже во время трекинга; поэтому ваш код, основанный на event.allTouches?.first?.phase, перестаёт работать как раньше.
  • Пока нет подтверждения из релиз-нот Apple (нужен отдельный поиск/bug report), логично считать это либо регрессией, либо намеренным поведением (оптимизация/унификация генерации UIEvent). Для оценки популярности ситуации см. данные по iOS 26 в поисковой аналитике (Yandex Wordstat — ios 26).

В итоге: полагаться на event.allTouches в handler’e .valueChanged больше нельзя считать надёжным кросс‑версийным решением.


Быстрые обходы: подписка на touch-события и проверка isTracking

Если нужно быстро вернуть контроль фаз (began/moved/ended/cancelled), используйте стандартные UIControl-события и свойство isTracking. Это максимально просто и не требует подклассов.

Пример:

swift
slider.isContinuous = true

slider.addTarget(self, action: #selector(sliderTouchDown(_:)), for: .touchDown)
slider.addTarget(self, action: #selector(sliderValueChanged(_:event:)), for: .valueChanged)
slider.addTarget(self, action: #selector(sliderTouchUp(_:)), for: [.touchUpInside, .touchUpOutside])
slider.addTarget(self, action: #selector(sliderTouchCancel(_:)), for: .touchCancel)

И реализация:

swift
@objc func sliderTouchDown(_ sender: UISlider) {
 print("начато")
}

@objc func sliderValueChanged(_ sender: UISlider, event: UIEvent?) {
 if sender.isTracking {
 print("перемещено — текущие значения: (sender.value)")
 } else {
 // значение изменилось без трекинга (программно или через A11Y)
 print("valueChanged без трекинга")
 }
}

@objc func sliderTouchUp(_ sender: UISlider) {
 print("завершено — финальное значение: (sender.value)")
}

@objc func sliderTouchCancel(_ sender: UISlider) {
 print("отменено")
}

Почему это работает:

  • .touchDown / .touchUpInside/.touchUpOutside / .touchCancel — именно те события, которые отражают начало и конец взаимодействия с контролом.
  • sender.isTracking даёт простую проверку: если true — пользователь сейчас тащит ползунок; если false — изменение пришло не от текущего трека.

Недостатки: если система в iOS 26/устройстве ведёт себя нестандартно, потребуются дополнительные проверки (например, когда трекинг прерывается системно). Но в большинстве случаев этот набор покрывает сценарии начала/перемещения/окончания.


Надёжный подход: подкласс UISlider с переопределением tracking-методов

Если нужно максимально надёжно получать фазы от самого контрола, лучше подклассировать UISlider и обрабатывать beginTracking/continueTracking/endTracking/cancelTracking. Эти методы получают UITouchUIEvent?) напрямую — и их поведение стабильнее при системных изменениях.

Пример класса-обёртки:

swift
class TrackingSlider: UISlider {
 var onDragBegan: (() -> Void)?
 var onDragChanged: ((Float) -> Void)?
 var onDragEnded: ((Float) -> Void)?
 var onDragCancelled: (() -> Void)?

 override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
 let result = super.beginTracking(touch, with: event)
 onDragBegan?()
 return result
 }

 override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
 let result = super.continueTracking(touch, with: event)
 onDragChanged?(self.value)
 return result
 }

 override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
 super.endTracking(touch, with: event)
 onDragEnded?(self.value)
 }

 override func cancelTracking(with event: UIEvent?) {
 super.cancelTracking(with: event)
 onDragCancelled?()
 }
}

Применение:

swift
let slider = TrackingSlider()
slider.onDragBegan = { print("начато") }
slider.onDragChanged = { value in print("перемещено: (value)") }
slider.onDragEnded = { value in print("завершено: (value)") }
slider.onDragCancelled = { print("отменено") }

Плюсы:

  • Вы получаете точки входа именно в жизненный цикл трекинга контрола.
  • Менее зависимо от того, какой UIEvent OS вкладывает в action-метод.
  • Удобно для передачи в архитектуру MVVM/closure-коллбэков.

Учтите: endTracking(_:, with:) может получать touch == nil в редких сценариях, поэтому проверяйте значение и/или используйте внутреннюю переменную состояния.


Ещё варианты: UIGestureRecognizer и комбинации методов

Если подклассировать нельзя (например, вы используете готовую библиотеку), есть другие подходы:

  • UIPanGestureRecognizer на слайдере:
  • Добавьте UIPanGestureRecognizer(cancelsTouchesInView: false) к слайдеру и отслеживайте .began/.changed/.ended/.cancelled. Минус — возможные конфликты с внутренней обработкой UISlider.
  • Наблюдение за isTracking циклически (таймер) — не рекомендую (хрупко). Лучше react на события.
  • Перехват touchesBegan/... в родительском view — работает, но сложнее покрыть все случаи и аккуратно не ломать другие элементы.

Если кратко: сначала пробуйте комбинацию addTarget + isTracking, затем — подкласс. Gesture‑подходы — запасной вариант.


Тестирование и отчёт об ошибке (как воспроизвести и что приложить)

Чтобы понять, баг это или изменение поведения, сделайте так:

  1. Минимальный проект: новый Single View App, разместите UISlider и скопируйте ваш минимальный handler (тот, что в вопросе).
  2. Прогоните на реальных устройствах с iOS 25.x и iOS 26.x (симулятор не всегда отражает нюансы ввода).
  3. Повторите сценарии: тащение, быстрый свайп, отпускание внутри/снаружи, программное изменение slider.value = ..., включение Accessibility (VoiceOver), использование мыши/трекпада.
  4. Соберите лог, скриншоты, небольшой sample project и шаги воспроизведения.
  5. Если поведение повторилось — отправьте баг в Apple через Feedback Assistant (описание + sample project + логи). Укажите точные версии iOS 26 (26.0 / 26.x), модель устройства и шаги.

Дополнительно: мониторьте обсуждения и отчёты сообщества — сейчас тема iOS 26 активно ищется пользователями (см. Yandex Wordstat — ios 26). По точным терминам разработки (например, allTouches, UISlider) поисковая активность мала, но это проблема именно разработчиков (Yandex Wordstat — allTouches, Yandex Wordstat — uislider).


Пример полного кода: addTarget + subclass — готовые паттерны

Полный рабочий пример, комбинирующий быстрый обход (touch events) и подкласс:

swift
import UIKit

class TrackingSlider: UISlider {
 var onDragBegan: (() -> Void)?
 var onDragChanged: ((Float) -> Void)?
 var onDragEnded: ((Float) -> Void)?
 var onDragCancelled: (() -> Void)?

 override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
 let started = super.beginTracking(touch, with: event)
 onDragBegan?()
 return started
 }

 override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
 let cont = super.continueTracking(touch, with: event)
 onDragChanged?(self.value)
 return cont
 }

 override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
 super.endTracking(touch, with: event)
 onDragEnded?(self.value)
 }

 override func cancelTracking(with event: UIEvent?) {
 super.cancelTracking(with: event)
 onDragCancelled?()
 }
}

class ViewController: UIViewController {
 let slider = TrackingSlider()
 let label = UILabel()

 override func viewDidLoad() {
 super.viewDidLoad()

 slider.minimumValue = 0
 slider.maximumValue = 100
 slider.isContinuous = true

 slider.onDragBegan = { [weak self] in
 self?.label.text = "Начато"
 }
 slider.onDragChanged = { [weak self] value in
 self?.label.text = "Перемещено: (value)"
 }
 slider.onDragEnded = { [weak self] value in
 self?.label.text = "Завершено: (value)"
 }
 slider.onDragCancelled = { [weak self] in
 self?.label.text = "Отменено"
 }

 slider.addTarget(self, action: #selector(valueChanged(_:event:)), for: .valueChanged)

 // layout omitted
 }

 @objc func valueChanged(_ sender: UISlider, event: UIEvent?) {
 // fallback: если вдруг трекинг не используется, проверяем isTracking
 if sender.isTracking {
 // уже обрабатывается через callbacks
 return
 } else {
 // программное или другое изменение
 label.text = "Изменено программно: (sender.value)"
 }
 }
}

Этот паттерн даёт устойчивую обработку фаз и при этом сохраняет совместимость с программной установкой значения.


Источники


Заключение

Кратко: в iOS 26 не стоит полагаться на event.allTouches внутри обработчика .valueChanged у UISlider — вместо этого используйте подписку на явные UIControl.Event (.touchDown, .touchUpInside/.touchUpOutside, .touchCancel, .touchDragInside) и/или подклассируйте UISlider, переопределяя beginTracking/continueTracking/endTracking. Это вернёт вам надёжное обнаружение фаз began/moved/ended независимо от того, какие поля UIEvent заполняет система в конкретной сборке iOS 26. Параллельно воспроизведите баг на реальных устройствах и, если воспроизводится — отправьте отчёт в Apple.

Авторы
Проверено модерацией
Модерация
iOS 26: UISlider .valueChanged — allTouches nil