В 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 value3. lastUpdate: _value, __unclampedValue = target; _atTarget = true; _tracking = false4. fluid: _lockedValue, _presentationValue = target; __activeDriver = nil; __state = 05. __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_runtime -
Type Encodings (формат вида
v24@0:8d16): https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html -
WWDC25, «Build a UIKit app with the new design» (284): https://developer.apple.com/videos/play/wwdc2025/284/
ссылка на оригинал статьи https://habr.com/ru/articles/1055466/