INVISIBLE прячет view, но оставляет за ней место в разметке; GONE прячет её и убирает из разметки, так что соседи занимают освободившееся место. В обоих случаях view не рисуется и не получает касаний.
Стоимость: переход в GONE или из него вызывает requestLayout() и перемеряет родителя; VISIBLE ↔ INVISIBLE только перерисовывает.
**GONE-view всё равно существует**: она была заинфлейчена и занимает память. Для блока, который показывают редко, дешевле ViewStub.
**В ConstraintLayout** GONE-view сжимается в точку, но её констрейнты продолжают действовать; layout_goneMargin* задаёт соседу отступ на это время.
AndroidMiddle
View, разметка и RecyclerView
Разметка, ConstraintLayout, ViewBinding, свои View, measure/layout/draw, RecyclerView, DiffUtil
Закрыто вопросов
0 / 25
- 0Повторить
- 7Часто спрашивают
Вопросы
px обозначает физический пиксель, dp обозначает независимый от плотности пиксель, который имеет один и тот же физический размер на любом экране, а sp работает как dp, который ещё и масштабируется вместе с настройкой размера шрифта. Размеры текста задаются в sp, а всё остальное в dp.
**dp**: px = dp × density, где density равна 1.0 при 160 dpi (mdpi), 2.0 на xhdpi и 3.0 на xxhdpi. Настройка «Размер экрана» у пользователя тоже её меняет.
**sp**: только для textSize. Текст в dp игнорирует масштаб шрифта из настроек доступности.
**px**: только для кода отрисовки внутри кастомной View, никогда не в XML-разметке. Не пересчитывайте вручную: используйте TypedValue.applyDimension или getDimensionPixelSize; масштабирование шрифта нелинейное с Android 14 (подробности в теме про доступность).
Стиль представляет собой набор значений атрибутов для одной view; тема является набором, применённым к приложению, Activity или поддереву view, и она же задаёт атрибуты темы вроде colorPrimary, которые любая view может найти через ?attr/.
**style="@style/…" на view действует только на эту view, не на её детей.
android:theme** на <application>, <activity> или view каскадно распространяется на всё, что инфлейтится внутри, через ContextThemeWrapper.
**?attr/colorPrimary** значит «то, что сейчас указано в теме», и резолвится при инфлейте; @color/blue задаёт фиксированное значение. Именно атрибуты темы позволяют тёмной теме и перетемизированному поддереву работать без правки разметки.
День/ночь: наследуйтесь от Theme.Material3.DayNight и кладите переопределения в values-night/; AppCompatDelegate.setDefaultNightMode() переключает режим в рантайме и пересоздаёт Activity.
findViewById ищет view в рантайме и может вернуть null или не тот тип; ViewBinding генерирует на каждую разметку класс с типизированным полем для каждого android:id (nullable, только если view есть не во всех вариантах); DataBinding добавляет выражения и двустороннее связывание в XML.
ViewBinding: buildFeatures { viewBinding = true }; activity_main.xml превращается в ActivityMainBinding с inflate(), bind() и root. Без annotation processor, почти бесплатно для сборки. Вариант по умолчанию для кода на View.
DataBinding: требует обёртку <layout> и плагин kotlin-kapt, замедляет сборку и переносит логику в XML, где её трудно тестировать и отлаживать. Встречается в основном в легаси; новые экраны пишут на ViewBinding или Compose.
**findViewById**: именно его вызывает bind() внутри. Нормален в маленькой кастомной View, но на целом экране провоцирует ошибки.
RecyclerView создаёт ровно столько строк, сколько нужно для экрана, плюс несколько запасных, и переиспользует их при скролле: onCreateViewHolder инфлейтит новую строку, только когда в пуле нет свободной строки этого view type, а onBindViewHolder заполняет строку данными для позиции.
Adapter: превращает данные в строки: количество элементов, view type, создание и биндинг.
ViewHolder: хранит view строки и ссылки на её детей, чтобы биндинг никогда не вызывал findViewById.
LayoutManager: измеряет и расставляет строки и решает, какие ушли за экран (LinearLayoutManager, GridLayoutManager, StaggeredGridLayoutManager).
Переиспользование: ушедшая за экран строка сначала попадает в маленький кэш по позиции (по умолчанию 2, переиспользуется без повторного биндинга), а затем в RecycledViewPool по view type (по умолчанию 5 на тип), откуда её нужно биндить заново.
**Почему не ListView**: в ListView паттерн ViewHolder был необязательным, скролл только вертикальным, а анимаций элементов и обновлений notifyItem* не было. RecyclerView делает holder обязательным и разносит раскладку, декорации и анимации по подключаемым частям.
Bind вызывается гораздо чаще, чем create, поэтому он должен быть дешёвым и при каждом вызове выставлять каждую view.
Они выносят оформление и жесты списка из разметки строки: ItemDecoration добавляет отступы и разделители, ItemTouchHelper добавляет смахивание для удаления и перетаскивание для сортировки, а SnapHelper останавливает флинг так, чтобы элемент был выровнен.
**ItemDecoration**: getItemOffsets() добавляет место вокруг строки, onDraw() и onDrawOver() рисуют под строками или поверх них. Есть готовый MaterialDividerItemDecoration. Если вместо этого задать margin в разметке строки, отступы между элементами удваиваются, а первую и последнюю строку не обработать отдельно.
**ItemTouchHelper**: принимает SimpleCallback(dragDirs, swipeDirs): onMove переставляет, onSwiped удаляет. Данные нужно менять именно там, иначе смахнутая строка вернётся.
**SnapHelper**: LinearSnapHelper центрирует ближайший элемент; PagerSnapHelper сдвигает на один элемент за флинг, как пейджер.
Он позволяет собрать сложный экран одним плоским уровнем вместо вложенных LinearLayout и RelativeLayout. Это важно, потому что LinearLayout с весами и RelativeLayout измеряют детей дважды, и стоимость умножается на каждом уровне вложенности.
Почему важна плоская иерархия
LinearLayout с layout_weight измеряет взвешенных детей дважды; RelativeLayout делает так всегда. Три таких вложенных уровня могут измерить лист восемь раз за проход.
ConstraintLayout тоже может измерить ребёнка больше одного раза, но при одном уровне умножать нечего.
Инструменты
**0dp (MATCH_CONSTRAINT)**: заполнить пространство между двумя констрейнтами по этой оси. match_parent у ребёнка игнорирует констрейнты: классический баг «текст уезжает под кнопку». wrap_content с app:layout_constrainedWidth="true" подстраивается под контент, но никогда не пересекает констрейнт.
Цепочка (chain): view, привязанные друг к другу в обе стороны; chainStyle (spread, spread_inside, packed) и layout_constraintHorizontal_weight заменяют веса LinearLayout.
Guideline: невидимая линия на отступе в dp или в процентах.
Barrier: следует за самой широкой из нескольких view, так что колонка подписей переживает перевод на более длинный язык.
**layout_constraintDimensionRatio="16:9"**: вычисляет сторону 0dp из другой стороны.
Если вы вкладываете ConstraintLayout только ради весов, вам нужна цепочка, guideline или barrier.
LayoutInflater проходит по скомпилированному XML на главном потоке и создаёт объект View для каждого тега; <include> переиспользует другой файл разметки, <merge> убирает лишний корень этого файла, а ViewStub откладывает инфлейт части экрана до момента, когда она понадобится.
Где возникает стоимость: инфлейт выполняется внутри setContentView или onCreateView, до первого кадра. Каждый тег обходится в вызов конструктора (через рефлексию для кастомных view) плюс разбор атрибутов, поэтому количество и глубина view напрямую превращаются во время открытия экрана.
**<include>**: встраивает другую разметку. Атрибуты layout_* на теге переопределяют атрибуты корня только тогда, когда заданы и layout_width, и layout_height.
**<merge>**: корень, который не является View: его дети присоединяются прямо к включающему родителю, и уровень-обёртка исчезает. Добавьте tools:parentTag, чтобы превью понимало, как их расположить.
**ViewStub**: заглушка 0×0, которая ничего не рисует. inflate() (или установка видимости) инфлейтит настоящую разметку и подменяет собой заглушку, поэтому повторный inflate() бросает исключение. Подходит для панели ошибки или пустого состояния.
**Атрибуты tools:**: tools:text, tools:listitem; работают только в редакторе и вырезаются из сборки.
**AsyncLayoutInflater** переносит инфлейт с главного потока, но не справляется с разметкой, чьи view создают Handler; упрощение разметки обычно помогает сильнее.
Fragment живёт дольше своей view: после onDestroyView он остаётся в back stack, и поле binding удерживает мёртвое дерево view. Обнуляйте binding в onDestroyView и подписывайтесь через viewLifecycleOwner, а не lifecycleScope.
Исправьте поле: сделайте nullable _binding, присваивайте его в onViewCreated (или onCreateView) и обнуляйте в onDestroyView. Читайте через non-null геттер только между этими двумя колбэками.
Исправьте подписку: запускайте на viewLifecycleOwner.lifecycleScope с repeatOnLifecycle(Lifecycle.State.STARTED). Собственный lifecycleScope Fragment живёт до уничтожения Fragment, то есть намного дольше view.
**bind(view), а не inflate()**: конструктор Fragment(R.layout.…) уже заинфлейтил разметку.
Почему с Activity иначе: Activity и её view умирают вместе, поэтому lateinit-binding там не течёт.
View создаётся (из XML или в коде), присоединяется к окну, проходит measure, layout и draw в каждом кадре, где это нужно, и в конце отсоединяется. onAttachedToWindow и onDetachedFromWindow служат местами, где стоит запускать и останавливать всё, чем владеет view.
**Конструктор, затем onFinishInflate**: читаются атрибуты; onFinishInflate вызывается, когда все дети из XML уже созданы, и это удобное место, чтобы кастомная ViewGroup нашла их.
**onAttachedToWindow: view в окне: регистрируйте слушатели, запускайте анимации. Размер здесь всё ещё 0.
onMeasure → onSizeChanged → onLayout → onDraw**: запускаются из ViewRootImpl.performTraversals(), когда Choreographer доставляет следующий vsync. Measure и layout идут только после requestLayout(), а отрисовка только после invalidate(); onSizeChanged вызывается, только если размер действительно изменился.
Рендеринг: с аппаратным ускорением onDraw записывает команды в RenderNode этой view, а RenderThread отправляет их на GPU. View, которую не инвалидировали, заново не записывается, вместо этого проигрывается её старый display list.
**onDetachedFromWindow**: view убрали из окна или Activity уничтожена: остановите аниматоры, отпишите слушатели, отмените задачи. Строка RecyclerView отсоединяется и присоединяется заново при переиспользовании, так что для одного объекта эта пара может сработать много раз.
В onCreate дерево view только заинфлейчено; measure и layout выполняются позже, в первом кадре после присоединения окна, так что до этого любой размер равен 0. Дождитесь раскладки через doOnLayout, а не угадывайте.
**doOnLayout { } (core-ktx): выполняется сразу, если view уже разложена, иначе один раз после следующей раскладки. Обычный ответ.
doOnPreDraw { }: после раскладки, прямо перед отрисовкой кадра; удобно для подготовки transition.
view.post { }: работает, потому что runnable выполняется после первого traversal, но это порядок «по случайности», и последующие изменения размера он пропустит.
addOnLayoutChangeListener / OnGlobalLayoutListener: срабатывают после каждой раскладки; глобальный слушатель снимите, когда он больше не нужен, иначе он будет работать всю жизнь view.
В кастомной View** используйте onSizeChanged. Размер может снова измениться при повороте, появлении клавиатуры, в multi-window или на складном устройстве, поэтому код, который читает его один раз, хрупкий.
Родитель измеряет каждого ребёнка с помощью MeasureSpec, расставляет его через layout(), а затем каждая view рисует себя в onDraw. invalidate() вызывают, когда меняются только пиксели, а requestLayout() вызывают, когда может измениться размер или положение.
Measure
MeasureSpec упаковывает режим и размер в один Int: EXACTLY (match_parent в родителе с точным размером или фиксированные dp), AT_MOST (wrap_content, не больше этого размера), UNSPECIFIED (сколько угодно, например внутри ScrollView).
onMeasure обязан вызвать setMeasuredDimension(), иначе фреймворк бросит IllegalStateException. resolveSize() учитывает режим за вас.
Он может вызываться несколько раз за проход, поэтому должен быть дешёвым и без побочных эффектов.
Layout и draw
onLayout имеет смысл только во ViewGroup: вызывайте child.layout(l, t, r, b) с размерами из measure. Никогда не вызывайте внутри него requestLayout().
onDraw выполняется в каждом кадре, пока view инвалидирована, то есть до 120 раз в секунду. Держите Paint, Path и RectF в полях; аллокации там превращаются в мусор в каждом кадре (Lint предупреждает DrawAllocation).
Padding учитывайте сами: canvas начинается с 0,0, а не с paddingLeft.
invalidate или requestLayout
invalidate() запускает только перерисовку: прогресс, цвет, выделение.
requestLayout() поднимается до корня и планирует measure и layout для ветки, а потом отрисовку. Вызов из сеттера, который аниматор дёргает каждый кадр, впустую сжигает бюджет кадра.
Объявите атрибуты в <declare-styleable> и прочитайте их в конструкторе через obtainStyledAttributes; для состояния переопределите onSaveInstanceState и onRestoreInstanceState с подклассом BaseSavedState. У view должен быть android:id, иначе ничего не сохранится.
Чтение атрибутов: context.obtainStyledAttributes(attrs, R.styleable.RatingView, defStyleAttr, 0) возвращает TypedArray; его нужно освободить (recycle), и use { } из core-ktx делает это за вас.
Конструкторы: @JvmOverloads генерирует конструктор (Context, AttributeSet), который вызывает инфлейт из XML. Если наследуетесь от виджета вроде Button, передавайте его атрибут стиля по умолчанию вместо 0, иначе потеряете его стандартный вид.
Сохранение: оберните результат super.onSaveInstanceState() как superState; если его потерять, пропадёт состояние базового класса.
Восстановление: разверните свой SavedState, передайте superState наверх, затем прочитайте свои поля.
Правило id: состояние хранится в SparseArray по id: без id нет и состояния, а две view с одинаковым id перезаписывают ячейку друг друга.
Что здесь хранить: чисто UI-состояние вроде флага «развёрнуто». Данные, которые показывает экран, живут во ViewModel.
Когда кадры производятся вне главного потока и с высокой частотой: видео, превью камеры, игра или рендерер карты. SurfaceView даёт производителю собственную поверхность, которую компонует система; TextureView встраивает кадры в обычную иерархию view как GPU-текстуру.
Обычная View рисует в onDraw на главном потоке в единственную поверхность окна. Для UI это нормально, а для непрерывного рендеринга становится узким местом.
**SurfaceView**: отдельная Surface, которую компонует SurfaceFlinger, часто как аппаратный оверлей. Лучшие расход батареи и задержка, обязательна для видео с DRM, рисовать можно из любого потока (SurfaceHolder.lockCanvas(), OpenGL, Vulkan). С Android 7.0 она двигается синхронно с соседними view, но трансформации и полупрозрачность по-прежнему ограничены.
**TextureView: ведёт себя как любая view: alpha, поворот, обрезка, скролл внутри списка. Цена включает лишнее копирование на GPU, больше памяти и задержку в кадр и больше; нужно аппаратное ускорение.
Выбор по умолчанию**: SurfaceView; Media3 PlayerView и CameraX PreviewView используют её по умолчанию. Переходите на TextureView, только если контент должен анимироваться, перекрываться или трансформироваться как view.
View анимируют через property-аниматоры (ValueAnimator, ObjectAnimator, view.animate()), Transitions, MotionLayout и spring/fling-анимации. Measure и layout пропускают только translation, scale, rotation и alpha; анимация layoutParams.height перекладывает ветку в каждом кадре.
API
Property-аниматоры: ValueAnimator, ObjectAnimator, view.animate(). Меняют настоящие свойства, так что view находится там, где её видно, и касания попадают туда же.
View-анимации (res/anim, TranslateAnimation) считаются легаси: двигают только пиксели, а область касания остаётся на месте.
Transitions: TransitionManager.beginDelayedTransition(root) анимирует изменения разметки, сделанные сразу после вызова; сюда же shared-element переходы между экранами.
**MotionLayout: ConstraintLayout, который анимирует переход между двумя наборами констрейнтов, в том числе по свайпу или скроллу.
Физика**: SpringAnimation и FlingAnimation из androidx.dynamicanimation: прерываемые и учитывающие скорость.
Как оставаться на дешёвом пути
Предпочитайте четыре свойства RenderNode (translation, scale, rotation, alpha): они меняют лишь то, как рисуется уже записанная view, поэтому RenderThread применяет их без measure и layout. Размер анимируйте, только когда разметка действительно должна измениться.
В этом случае beginDelayedTransition один раз измеряет конечное состояние и анимирует границы вместо раскладки в каждом кадре.
withLayer() на время анимации рендерит сложную view в аппаратный слой, чтобы fade не перерисовывал её детей в каждом кадре.
notifyDataSetChanged() говорит «изменилось всё»: каждая видимая строка биндится заново, ничего не анимируется, позиция скролла теряется. ListAdapter запускает DiffUtil между старым и новым списком в фоне и отправляет точные события вставки, удаления, перемещения и изменения.
**areItemsTheSame: это та же сущность? Сравнивайте только id. Если сравнивать объекты целиком, любое редактирование превращается в удаление плюс вставку, и строка мигает.
areContentsTheSame**: выглядит ли она так же? Вызывается только для того же элемента; equals у data class обычно подходит, если модель неизменяемая.
Payloads: getChangePayload возвращает, что именно изменилось; трёхаргументный onBindViewHolder(holder, position, payloads) обновляет только эту view, без кроссфейда. Пустой payloads означает полный bind.
**submitList нужен новый список. Если изменить уже отправленный список и отправить его снова, список сравнится сам с собой, и ничего не обновится.
Без ListAdapter**: notifyItemChanged(position, payload), notifyItemInserted и подобные делают то же самое для одного известного изменения.
toggleLike копирует список, но меняет общий Post на месте, поэтому меняется и список, который уже лежит в адаптере. StateFlow видит равный список и ничего не эмитит, а строка показывает новое состояние только при следующем ребинде.
**toMutableList() делает поверхностную копию**: новый список содержит те же объекты Post, что и отправленный. Изменение liked переписывает и состояние «до», так что сравнивать не с чем.
Изменение теряется на двух уровнях: MutableStateFlow отбрасывает значение, которое equals текущему, а два списка с одинаковыми элементами равны. С LiveData список бы дошёл, но areContentsTheSame сравнил бы каждый объект сам с собой, и DiffUtil не отправил бы ни одного события.
Почему скролл «чинит» баг: ребинд читает уже изменённый объект, поэтому сердечко появляется, когда строка уходит за экран и возвращается.
Исправление: сделать UI-модель неизменяемой (поля val) и создавать новый элемент через copy() внутри update { }. Новый список отличается, StateFlow эмитит, а DiffUtil сообщает ровно об одном изменении.
Как поймать в следующий раз: никаких var в моделях, которые уходят в ListAdapter (правило ревью или Detekt), и тест ViewModel, проверяющий, что posts эмитит новое значение после toggleLike.
Holder представляет собой переиспользованную строку, на которой всё ещё видно то, что выставил предыдущий элемент, поэтому bind обязан задавать каждую view на каждой ветке. Здесь бейдж больше никогда не прячется, а колбэк с фото пишет в строку, которая, возможно, уже показывает другой элемент.
**if без else**: badge.isVisible = item.isUrgent задаёт оба состояния.
Асинхронный результат в переиспользованную view: картинка приходит, когда строку уже перебиндили на другую позицию. Coil или Glide привязывают запрос к ImageView и отменяют предыдущий; со своим загрузчиком очищайте картинку в bind и отменяйте загрузку в onViewRecycled.
**Захваченный position**: лямбда хранит позицию на момент bind, и после вставки или перемещения она неверна. Ставьте слушатель один раз в onCreateViewHolder и читайте bindingAdapterPosition в момент клика.
Новый слушатель на каждый bind: лишняя аллокация на каждом шаге скролла.
Незавершённые анимации: отменяйте их в onViewRecycled, иначе недоигранный fade окажется на новом элементе.
Bind присваивает isChecked, пока на переиспользованной строке ещё висит листенер предыдущего элемента, а setChecked вызывает его синхронно. Предыдущая задача сохраняется с состоянием новой, и скролл незаметно переписывает данные.
Листенер переживает свой bind: лямбда захватила task из последнего bind этого холдера. Когда строку переиспользуют для другой позиции, старая лямбда всё ещё подключена в момент, когда меняется isChecked.
**CompoundButton не знает, кто его изменил**: OnCheckedChangeListener срабатывает на любое реальное изменение состояния, из кода или от нажатия. Поэтому ломаются только некоторые строки (те, где старое и новое done различаются).
Исправление: вызвать setOnCheckedChangeListener(null) до присваивания isChecked, затем подключить новый листенер. Как альтернатива подойдёт один листенер в onCreateViewHolder, который читает bindingAdapterPosition и игнорирует вызовы, где checked == item.done.
Та же ловушка: любая view, чей сеттер вызывает колбэк: SwitchMaterial, RadioGroup, Slider, TextWatcher на EditText внутри строки.
Как поймать в следующий раз: инструментальный тест, который скроллит список со смешанными состояниями и проверяет, что данные не изменились; на ревью отмечать порядок «сначала состояние, потом листенер» в каждом bind.
Возвращайте свой getItemViewType для каждого вида строки и создавайте соответствующий ViewHolder в onCreateViewHolder. Отдельные секции вроде шапки или футера «загрузить ещё» выносите в собственные адаптеры, объединённые ConcatAdapter.
Пул разделён по view type, поэтому holder заголовка никогда не достанется карточке. Id разметки служит уникальным и читаемым view type.
**Sealed interface плюс исчерпывающий when: новый тип строки не скомпилируется, пока его не обработают.
ConcatAdapter** избавляет от арифметики position - 1: каждый адаптер видит свои позиции. В holder bindingAdapterPosition возвращает позицию в собственном адаптере, а absoluteAdapterPosition возвращает позицию во всём списке.
DiffUtil: areItemsTheSame должен сравнивать не только id, но и класс, ведь у рекламы и статьи id могут совпасть.
Много типов и несколько команд: delegate-адаптеры (AdapterDelegates, Epoxy, Groupie) применяют ту же идею: один класс на тип строки.
Сначала измерьте: трасса в Perfetto или профайлер Android Studio покажет, какие слайсы RecyclerView (RV OnBindView, RV CreateView, RV OnLayout) не укладываются в кадр. Затем идите по причинам ниже по порядку.
Биндинг: никакого I/O, тяжёлого форматирования (дат, текста) и новых слушателей в onBindViewHolder. Готовьте модели для отображения во ViewModel.
Картинки: загружайте через Coil или Glide под размер view. Декодирование полноразмерного bitmap на главном потоке считается классической причиной.
Инфлейт во время скролла: много слайсов RV CreateView значит, что пул пустеет: упрощайте разметку строк, используйте один общий RecycledViewPool для вложенных каруселей и задавайте setInitialPrefetchItemCount их горизонтальным LayoutManager.
Раскладка: setHasFixedSize(true), когда изменения контента не меняют размер самого списка; избегайте вложенных весов в строках; никогда не кладите RecyclerView внутрь ScrollView (тогда все строки инфлейтятся сразу).
Полный перебиндинг: notifyDataSetChanged() виден как RV FullInvalidate; переходите на ListAdapter.
Overdraw: фон на каждом слое стоит GPU-времени в каждом кадре.
Тестируйте release-сборку с baseline profile на телефоне среднего класса; debug-сборка на флагмане мало что скажет.
Дети CoordinatorLayout реагируют друг на друга через Behavior, а список сообщает о своём скролле через nested scrolling, поэтому AppBarLayout может поглотить скролл и свернуться первым. RecyclerView и NestedScrollView это поддерживают; обычный ScrollView не поддерживает.
Nested scrolling
Прежде чем скроллиться самому, ребёнок вызывает dispatchNestedPreScroll; родитель может поглотить часть расстояния, и так app bar сворачивается раньше, чем двигается список.
После скролла dispatchNestedScroll передаёт непоглощённый остаток, и так панель разворачивается, когда список дошёл до верха. Флинги идут через такую же пару.
app:layout_behavior="@string/appbar_scrolling_view_behavior" на списке ставит его под app bar и сдвигает по мере сворачивания панели.
Scroll flags на ребёнке AppBarLayout
scroll|exitUntilCollapsed сворачивается до высоты тулбара и остаётся так.
scroll|enterAlways появляется при любом скролле вверх; добавьте snap, чтобы не было полуоткрытых состояний.
Внутри CollapsingToolbarLayout layout_collapseMode="parallax" или "pin" определяет, как двигается каждый ребёнок.
Подводные камни
nestedScrollingEnabled="false" на списке или список внутри контейнера без nested scrolling молча отключают сворачивание.
ComposeView, который скроллится внутри, должен сообщать о скролле через rememberNestedScrollInteropConnection().
Собственные реакции (FAB, который поднимается над Snackbar) реализуются как Behavior, подключённый через CoordinatorLayout.AttachedBehavior или app:layout_behavior.
MotionEvent спускается через dispatchTouchEvent: каждая ViewGroup может перехватить его в onInterceptTouchEvent, иначе оно уходит ребёнку под пальцем, а необработанные события всплывают обратно через onTouchEvent. Кто вернул true на ACTION_DOWN, тот получает весь остальной жест.
Правила
**ACTION_DOWN выбирает получателя.** View, вернувшая false на DOWN, больше ничего из этого жеста не получит.
Родитель может перехватить и позже, обычно на ACTION_MOVE. Как только onInterceptTouchEvent вернул true, ребёнок получает ACTION_CANCEL, а остальные события идут в onTouchEvent родителя.
Ребёнок может запретить перехват через parent.requestDisallowInterceptTouchEvent(true); предки перестают перехватывать до конца жеста.
**OnTouchListener срабатывает раньше onTouchEvent**; если он вернул true, onTouchEvent пропускается вместе с обработкой клика.
Touch slop: решайте только после того, как палец сместился больше чем на ViewConfiguration.scaledTouchSlop и направление понятно. Перехват на DOWN ломает все клики внутри.
Типичный конфликт
Горизонтальная карусель внутри вертикального списка: внешний RecyclerView перехватывает жест, как только вертикальное смещение превысило slop, и диагональный свайп скроллит не тот список.
Исправляют обёрткой вокруг внутреннего списка: на DOWN запретить перехват, на MOVE снова разрешить, если жест преимущественно вертикальный. В примерах ViewPager2 есть похожая обёртка NestedScrollableHost.
View не потокобезопасны, а проверка живёт в ViewRootImpl.checkThread(), которая работает только после присоединения view к окну. В onCreate ViewRootImpl ещё нет, поэтому запись проскакивает по везению; после присоединения она бросает CalledFromWrongThreadException.
Что её вызывает: setText вызывает requestLayout() и invalidate(), и оба поднимаются до ViewRootImpl, которая проверяет поток.
Правило: трогайте view только на том потоке, который создал её ViewRootImpl, то есть на практике на главном.
**Случай с onCreate объясняется гонкой, а не разрешением. Если поток выполнится после присоединения окна, приложение упадёт; если выполнится во время traversal, может испортить состояние вообще без исключения.
Исправление**: выполняйте работу вне главного потока, а результат доставляйте на него: lifecycleScope.launch { val text = withContext(Dispatchers.IO) { repository.load() }; binding.status.text = text }. Также подойдут view.post { } и runOnUiThread { }.
**postInvalidate(): единственный запрос на перерисовку, безопасный из любого потока.
Настоящая отрисовка вне главного потока**: ровно то, для чего существуют SurfaceView и TextureView. Для тяжёлого текста PrecomputedTextCompat считает раскладку текста в фоновом потоке и отдаёт результат главному.
Приложения с targetSdk 35 на Android 15 работают в режиме edge-to-edge принудительно: окно заходит под статус-бар и панель навигации, setStatusBarColor игнорируется, и каждая view у края должна сама применять WindowInsets.
Что изменилось
Edge-to-edge принудителен на Android 15+ для targetSdk 35. Вызывайте enableEdgeToEdge(), чтобы старые версии вели себя так же и тестировать приходилось одну раскладку.
window.statusBarColor и navigationBarColor объявлены устаревшими и ничего не делают; контраст иконок управляется через WindowInsetsControllerCompat.isAppearanceLightStatusBars.
Атрибут отказа windowOptOutEdgeToEdgeEnforcement работает только на Android 15; приложения с targetSdk 36 (Android 16) отказаться уже не могут.
Применение инсетов
Тулбар: верхний padding из systemBars() вместе с displayCutout().
Список: нижний padding плюс clipToPadding = false, чтобы строки уходили под панель навигации, но последняя останавливалась над ней.
Клавиатура: в режиме edge-to-edge adjustResize больше не уменьшает контент за вас; читайте WindowInsetsCompat.Type.ime() и сами задавайте отступ полю ввода. WindowInsetsAnimationCompat.Callback двигает его синхронно с анимацией клавиатуры.
Возвращайте инсеты из слушателя; WindowInsetsCompat.CONSUMED оставит без них все следующие view.
Компоненты Material вроде BottomNavigationView применяют инсеты сами; AppBarLayout требует android:fitsSystemWindows="true".