UISlider изнутри: почему `setValue` не работает во время settle-анимации и как я это обошел

от автора

В 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 мог остаться на минимуме трека.

Если пропустить шаг

Если пропустить

Симптом

pan.cancel

Visual element пушит pre-cancel target через _directDrivingDelegate на следующем layout.

data._value

Геттер UISlider.value возвращает старое значение наружу.

lastUpdate.*

Driver считает, что предыдущая анимация ещё не завершилась, на следующем тике снова входит в settle.

fluid._lockedValue

Settle-driver подтягивает старую цель обратно.

fluid.__activeDriver

Существующий settle продолжает работать до естественного завершения display link.

__animatedValue.setValue:

Главный кэш. Даже если все остальные поля сброшены, animatable property держит spring target и тянет slider обратно на следующем кадре.

Финальный setValue

Побочный эффект pan.cancel оставляет thumb на минимуме трека.

Что нельзя трогать

__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 констрейнтов – это очень затратные операции.

  • Потеря текущего жеста. Если пересоздать слайдер, пока палец на нём, жест оборвётся. Если в вашем случае замена на предполагает продолжение движения (а скорее всего нет), вам это подходит.

Ссылки:

ссылка на оригинал статьи https://habr.com/ru/articles/1055466/