В iOS 26 у UISlider появился liquid-glass-вид и физика доводки (settle) после того, как пользователь отпускает палец. Если честно, я не проверял, как это выглядело в старых версиях и как оно работало, так как до iOS 26 ни в своих проектах, ни в тех, что я писал на работе, я не использовал стандартный компонент, так как его внешний вид никого не устраивал. У такой доводки есть побочный эффект: если в этот момент дернуть setValue(_:animated:) извне, наш слайдер на один кадр едет в новую точку, а потом откатывается туда, куда его тянет settle. removeAllAnimations() не помогает: анимация идёт не через CABasicAnimation, а через property-driver на display link. Дальше про то, как я нашёл рабочий путь это исправить.
Самое неприятное в этом баге было не само дёрганье, а ощущение, что публичный API говорит "значение поставлено”, а визуальный слой через кадр отвечает “нет, я лучше вернусь куда хотел”. Поэтому я отдельно разделю два пути: runtime-фикс для понимания механики и продовый обход (хотя в личном приложении я пошел бы в прод с фиксом).
Симптом
У меня есть кастомный слайдер интенсивности в редакторе. Внешний код иногда зовёт у него setValue, причём ровно в тот момент, когда пользователь только что отпустил палец: например пришло новое значение из другого места UI и его надо отразить на ползунке.
На iOS 18 и ниже это работало без вопросов просто по той причине, что использовался кастомный слайдер. но на iOS 26 мы перешли на использование стандартного слайдера. Вообще, изначально я из-за некоторых особенностей стандартного слайдера и уже наличия кастомного пытался реализовать thumb отдельно поверх кастомного, что даже работало, но не совсем так, как хотелось бы (опять же, с помощью создания приватного в iOS 26 класса линзы, которую довольно непросто было дорабатывать до желаемого состояния). В итоге я всё же решил использовать нативный слайдер, в том числе, потому что в нашем приложении один из критериев, брать что-либо или нет в улучшение, – как сделано у конкурента, котоорый достаточно хорошо работает. В итоге проблема – я ставлю значение в 0, а ползунок едет в 0 на один кадр и тут же откатывается обратно к той точке, куда его несло после отпускания пальца.
Первая версия была банальная: остаточная анимация на слое.
slider.setValue(0, animated: false) slider.layer.removeAllAnimations()
Не помогло вообще. Ползунок откатывается так же. Вот это и была первая зацепка: если removeAllAnimations() молчит, значит доводку крутит не CABasicAnimation. Что-то другое читает старый target на каждом кадре и тянет thumb обратно.
Сначала я попробовал тупое и быстрое
Прежде чем лезть в runtime, я честно отработал два простых варианта. Оба чинили симптом частично.
Первый вариант: отложить внешний setValue на время анимации. Это должно было выглядеть не так красиво, но лучше, чем ничего. Притом я пробовал вариант выставлять новое значение в endTracking, но это не сработало, в итоге помог только второй вариант: через DispatchQueue.main.asyncAfter. Дёрганье уходит, но значение применяется с задержкой, и при быстром переключении пользователь видит лаг. Полумера, которая лечит картинку, но не причину, да ещё и выглядит плохо.
Оба обхода не трогали причину. После них я сел разбираться, что именно iOS делает после endTracking.
Диагностика: что такое settle и почему removeAllAnimations не делает ничего
После endTracking UISlider запускает доводку через property-driver, привязанный к display link. На каждом тике дисплея driver читает кэшированный target и интерполирует к нему значение. Это не объект CAAnimation на слое, поэтому removeAllAnimations() его не видит и не трогает.
Схема того, что происходит на каждом кадре:
display link tick └─> settle-driver читает кэш target (несколько приватных ivars) └─> интерполирует presentationValue к target └─> двигает thumb
Смотрел я это вручную через po и expr в lldb по объекту slider, дамп subviews. Чтобы не ковыряться вслепую, я написал маленький ObjC-хэлпер SafeKVC для рефлексии: он умеет дампить ivars и методы любого объекта и безопасно их читать/писать. Сигнатуры основной части:
+ (NSArray<NSString *> *)ivarNamesOf:(id)object includeSuperclasses:(BOOL)inc; + (NSArray<NSString *> *)methodNamesOf:(id)object includeSuperclasses:(BOOL)inc; + (nullable id)valueForKey:(NSString *)key on:(id)object; // @try/@catch вокруг KVC + (nullable id)ivar:(NSString *)name on:(id)object; // object_getIvar
Плюс я собрал отдельный debug-стенд: изолированный экран с одним слайдером и кнопками, которые дёргают его внутренности по одной, чтобы видеть, какой именно ivar за что отвечает.
Карта internals
Вот граф классов, который собрался после дампа. Это iOS 26 с liquid-glass-вариантом визуального элемента.
UISlider ├─ _data: UISliderDataModel (KVC работает) ├─ _visualElement: _UISliderGlassVisualElement (iOS 26 Liquid Glass) │ : _UISliderFluidVisualElement (родитель, iOS 26 Fluid) │ ├─ data: UISliderDataModel (KVC) │ ├─ lastUpdate: _UIFluidSliderInteractionUpdate (только Ivar, KVC не работает) │ ├─ fluidInteraction: _UIFluidSliderInteraction (только Ivar, KVC не работает) │ ├─ clipView / barView / trackView / tickViews / ... │ ├─ lensView: _UIFluidGlassLensView (только iOS 26) │ ├─ thumbImageView, defaultThumbTintColor (только iOS 26) │ ├─ minimumThumbHitSize (только iOS 26) │ └─ usingSliderStyling, paddingAroundImage, ... ├─ _dummyViews ├─ _sliderConfiguration: _UISliderConfiguration ├─ _sliderStyle, _preferredBehavioralStyle └─ _visualElementFlags (битовое поле)
_UISliderGlassVisualElement наследуется от _UISliderFluidVisualElement. Glass-вариант добавляет всего несколько визуальных ivars (lensView, thumbImageView, defaultThumbTintColor, minimumThumbHitSize), а вся driver/state-машина (data, lastUpdate, fluidInteraction, clipView и прочее) живёт на родителе Fluid.
Почему KVC частично сломан
KVC на visual element работает не для всего. valueForKey: кидает NSUnknownKeyException для lastUpdate и fluidInteraction, хотя оба физически есть как Ivar на родительском классе. А для data KVC работает нормально.
Type encodings там тоже местами странные: у некоторых ivars ivar_getTypeEncoding(...) отдаёт пустую строку (в дампах это выглядело как ": :"). Похоже, Apple либо переиспользовала слот под Swift-managed property, либо вычистила метаданные акцессоров. Сам Ivar при этом доступен через runtime. Значит до этих полей лезем через class_getInstanceVariable + object_getIvar / object_setIvar, минуя KVC.
Что в каждом классе
UISliderDataModel (data):
_value : f // float, авторитетный raw value _minValue : f _maxValue : f _minEnabledValue: f _maxEnabledValue: f _continuous, _enabled, _highlighted, _selected, _tracking : B
_value тут авторитетное хранилище raw value слайдера, пишется через KVC с NSNumber(value: Float). Это первый шаг принудительной смены значения, без него геттер UISlider.value отдаёт наружу старое.
_UIFluidSliderInteractionUpdate (lastUpdate): снимок последнего апдейта от driver. Driver смотрит на _atTarget, чтобы решить, продолжать ли анимацию дальше.
_tracking : B // BOOL _atTarget : B _value : d // double _interactionState : q // long long enum _type : q __unclampedValue : d // ДВА подчёркивания в начале (такое ещё встречается ниже) _trackBounds : {CGRect} _barFrame : {CGRect} _trackTransform : {CGAffineTransform}
_UIFluidSliderInteraction (fluidInteraction): сама стейт-машина settle-анимации.
_presentationValue : d // текущее отрисованное значение _lockedValue : d // target, к которой driver тянет анимацию _locked : B _directDrivingDelegate : @"<_UIFluidSliderDirectDrivingDelegate>" // → _UISliderGlassVisualElement _configuration : @"_UIFluidSliderInteractionConfiguration" __drivers : @"NSArray" // [panDriver, volumeButtonDriver, ...] __activeDriver : @"<_UIFluidSliderDriving>" // nil в settled-состоянии __panDriver : @"<_UIFluidSliderDirectDriving>" // _UIFluidSliderElasticPanDriver __volumeButtonDriver : @"<_UIFluidSliderVolumeButtonDriving>" __animatedValue : @"UIViewFloatAnimatableProperty" __state : q __normalizedTrackSize : {CGSize}
UIViewFloatAnimatableProperty (__animatedValue): таблица селекторов с реальными type encodings из дампа.
объект ObjC instance method сигнатура ------ ---------------------- --------- __animatedValue setValue: v24@0:8d16 (void, double-аргумент) __animatedValue setVelocity: v24@0:8d16 (void, double-аргумент) __animatedValue value d16@0:8 (double-геттер) __animatedValue presentationValue d16@0:8 __animatedValue velocity d16@0:8
_UIFluidSliderElasticPanDriver (__panDriver): обработчик жеста pan, селекторы cancel и stop.
- (void)cancel; - (void)stop; - (BOOL)gestureRecognizerShouldBegin:(...) - (void)handleGesture:(...);
Главный кэш: __animatedValue
Вот тут важная деталь, на которой я завис надолго. Даже когда я сбросил data._value, lastUpdate, _lockedValue и _presentationValue, slider на следующем display tick всё равно интерполировал обратно. Target оказался продублирован ещё в одном месте, и это __animatedValue (UIViewFloatAnimatableProperty).
Это обёртка над spring-свойством. Backing-ivar у неё _animatableProperty, и это Swift-тип UIKit.BridgedProperty, до которого через ObjC runtime не достучаться. Зато ObjC-метод setValue: на самой обёртке, как оказалось, работает нормально. Туда нужно записать новое значение и обнулить скорость, иначе spring держит старый target и тянет thumb обратно на следующем кадре.
Вызываю через приведение IMP, потому что аргумент тут примитивный double (v24@0:8d16), а не объект:
SEL setValueSel = NSSelectorFromString(@"setValue:"); SEL setVelocitySel = NSSelectorFromString(@"setVelocity:"); ((void(*)(id,SEL,double))[animated methodForSelector:setValueSel])(animated, setValueSel, target); ((void(*)(id,SEL,double))[animated methodForSelector:setVelocitySel])(animated, setVelocitySel, 0);
В моём Swift-коде это два вызова через SafeKVC: invoke("setValue:", withDouble: target, on: animated) и invoke("setVelocity:", withDouble: 0, on: animated).
Хелпер: запись ivar по offset с проверной на тип
До _lockedValue, _presentationValue, lastUpdate._value и __unclampedValue через KVC не достучаться, а ещё там везде double, и KVC отказывается принимать float по этим слотам, так что пришлось записывать их напрямую по offset, соответственно, для SafeKVC потребовались следующие методы:
+ (void)setValue:(nullable id)value forKey:(NSString *)key on:(id)object; // KVC + @try/@catch + (void)setIvar:(nullable id)value forName:(NSString *)name on:(id)object; // object_setIvar + (void)setDoubleIvar:(double)value forName:(NSString *)name on:(id)object; // только enc == "d" + (void)setBoolIvar:(BOOL)value forName:(NSString *)name on:(id)object; // enc "B" или "c" + (void)setLongIvar:(long long)value forName:(NSString *)name on:(id)object; // enc "q"/"Q" + (void)invoke:(NSString *)selectorName withDouble:(double)arg on:(id)object; // IMP-приведение
Механика внутри простая и важная для безопасности. Поиск ivar идёт по цепочке классов вверх до NSObject через class_getInstanceVariable + class_getSuperclass. Запись примитива проверяет ivar_getTypeEncoding, и только если encoding совпал, пишет по адресу:
// внутри setDoubleIvar:forName:on: после проверки, что encoding == "d" *(double *)((__bridge void *)object + ivar_getOffset(ivar)) = value;
Проверка по encoding тут очень важна: у UISliderDataModel._value тип f (float), а у lastUpdate._value и fluidInteraction._lockedValue тип d (double). Если перепутать и записать double по float-слоту, очевидно, что получим мусор в соседних байтах просто из-за того, что double в памяти занимаешь вдвое больше места. setDoubleIvar: пишет только когда ivar_getTypeEncoding == "d", иначе ничего не делает. Аналогично setBoolIvar: и setLongIvar: проверяют свои encodings.
И вся KVC/invoke-часть обёрнута в @try/@catch (NSException *). NSUnknownKeyException и прочее проглатывается, метод тихо превращается в no-op вместо краша. Очевидно, что тут можем поймать ситуцию, когда API изменилось, а ошибку пропустили, но и код не пошел бы в прод, рисерч я в данном случае делал больше из интереса разобраться и починить.
Итог
У меня есть UIControl, который хранит в себе UISlider (для этого были ещё отдельные причины, связанные с изменением стандартного вида слайдера). Публичный setValue(_:animated:) у моего контрола дергает innerSlider.setValue(raw), а потом приватный метод forceFluidInteractionState(to: raw), который и делает всю работу. Последовательность из шести шагов, порядок тут критичен.
1. pan.cancel сбросить momentum (ставит thumb в минимум трека) 2. data._value = target KVC + NSNumber, обновляем raw value 3. lastUpdate: _value, __unclampedValue = target; _atTarget = true; _tracking = false 4. fluid: _lockedValue, _presentationValue = target; __activeDriver = nil; __state = 0 5. __animatedValue.setValue:(target); __animatedValue.setVelocity:(0) ← КЛЮЧЕВОЙ ШАГ 6. innerSlider.setValue(target, animated: false) для того, чтобы убрать побочку от шага 1 (thumb улетел в 0)
Почему именно так:
Шаг 1, pan.cancel первым. У cancel, вызванного посреди momentum-анимации, есть синхронный побочный эффект: он ставит значение слайдера в минимум трека (судя по всему, трактует cancel как «прервать interactive change и откатиться к начальному»). Поэтому его надо вызвать до установки нужного нам значения, а потом перезаписать всё поверх и в конце вернуть значение шагом 6.
Шаг 2, data._value. Без него геттер UISlider.value будет отдавать наружу старое значение, даже если визуально thumb встал правильно.
Шаг 3, lastUpdate. Без _atTarget = true и сброшенного _tracking driver считает прошлую анимацию незавершённой и снова входит в settle на следующем тике.
Шаг 4, fluidInteraction. _lockedValue это target доводки, _presentationValue текущее отрисованное значение, оба double, пишутся по offset. __activeDriver = nil гасит активный settle-driver. __state = 0 сбрасывает стейт-машину.
Шаг 5, __animatedValue. Тот самый ключевой шаг. Без него всё остальное бесполезно: spring держит старый target и возвращает thumb на следующем кадре.
Шаг 6, повторный setValue. Просто чистит за шагом 1: после cancel thumb мог остаться на минимуме трека.
Если пропустить шаг
Если пропустить | Симптом |
|---|---|
| Visual element пушит pre-cancel target через |
| Геттер |
| Driver считает, что предыдущая анимация ещё не завершилась, на следующем тике снова входит в settle. |
| Settle-driver подтягивает старую цель обратно. |
| Существующий settle продолжает работать до естественного завершения display link. |
| Главный кэш. Даже если все остальные поля сброшены, animatable property держит spring target и тянет slider обратно на следующем кадре. |
Финальный | Побочный эффект |
Что нельзя трогать
__panDriver, __drivers и __volumeButtonDriver обнулять нельзя. Это обработчики, которые переводят движение пальца и нажатия кнопок громкости в значение слайдера. Если их снести, thumb замёрзнет и перестанет реагировать на жесты.
Гасить надо именно __activeDriver, это ссылка на активный сейчас settle-driver. Слайдер при следующем жесте достанет нужный driver из __drivers и пересоздаст активный, так что пользовательское взаимодействие не ломается.
Что реально ушло в прод
Простая идея: когда мы создаём объект UISlider, у него нет никакой settle-анимации, чем мы и можем воспользоваться: во время анимации заменяем текущий слайдер на новый.
Теперь setValue(_:animated:) зовёт replaceInnerSlider(initialRawValue: raw), который:
снимает старый
innerSliderчерезremoveFromSuperview(),создаёт новый через фабрику
makeInnerSlider()(чистыйUISliderс нужным стилем трека:minimumTrackTintColorиmaximumTrackTintColorв.clear,semanticContentAttribute = .forceLeftToRight),переносит
minimumValue/maximumValue, ставитvalue = initialRawValue,заново вешает констрейнты и target/action через
installInnerSlider().
Этот вариант практичнее:
не зависит от приватных имён ivars и селекторов, переживает апдейты iOS;
нечему деградировать в no-op, потому что нет реверса, который бы тихо перестал работать и вернул баг;
проще читать и поддерживать.
Конечно же тут есть и недостаток: каждый внешний setValue пересоздаёт вью: аллокация нового UISlider, переустановка констрейнтов, и потеря текущего жеста, если палец в этот момент на слайдере. Для нашего паттерна это приемлемо, потому что внешний setValue приходит ровно тогда, когда пользователь не держит палец или не предполагается, что он так будет делать.
Грабли и риски
Риски были у обоих решений, и они разные.
У ivar-патча:
Приватный API. Имена ivars (
_lockedValue,__animatedValue,__activeDriverи компания) и селекторы (setValue:,cancel) могут поменяться после обновления iOS.SafeKVCпроглатываетNSUnknownKeyException. На новой iOS, где имя ivar поменялось, override просто скипает ошибку, но баг settle возвращается, и это легко не заметить.App Store review. Селекторы зовутся через
NSSelectorFromString, это ловят автосканеры Apple, а ручной ревью непредсказуем. В принципе, вызов этого метода сам по себе безопасный, но может вызвать вопросы. Одно из моих macOS-приложений не прошло ревью из-за использования приватного класса, притом много других приложений под iOS был спокойно опубликованы в AppStore.
У пересоздания, которое в проде:
Производительность. Каждый внешний
setValueсоздаёт новыйUISliderплюс reinstall констрейнтов – это очень затратные операции.Потеря текущего жеста. Если пересоздать слайдер, пока палец на нём, жест оборвётся. Если в вашем случае замена на предполагает продолжение движения (а скорее всего нет), вам это подходит.
Ссылки:
UISlider: https://developer.apple.com/documentation/uikit/uislider
Objective-C Runtime (
class_getInstanceVariable/object_getIvar/object_setIvar/ivar_getOffset/ivar_getTypeEncoding): https://developer.apple.com/documentation/objectivec/objective-c_runtimeType Encodings (формат вида
v24@0:8d16): https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.htmlWWDC25, «Build a UIKit app with the new design» (284): https://developer.apple.com/videos/play/wwdc2025/284/
