INVISIBLE hides the view but keeps its space in the layout; GONE hides it and removes it from the layout, so its neighbours move into the freed space. Neither draws or receives touches.
Cost: switching to or from GONE calls requestLayout() and re-measures the parent; VISIBLE ↔ INVISIBLE only redraws.
**A GONE view still exists**: it was inflated and holds memory. For a block that is rarely shown, a ViewStub is cheaper.
**In ConstraintLayout** a GONE view shrinks to a point but its constraints still hold; layout_goneMargin* sets the neighbour's margin meanwhile.
AndroidMiddle
Views, Layouts & RecyclerView
Layouts, ConstraintLayout, ViewBinding, custom views, measure/layout/draw, RecyclerView, DiffUtil
Questions closed
0 / 25
- 0Review
- 7Asked often
Questions
px is a physical pixel, dp is a density-independent pixel that has the same physical size on every screen, and sp is a dp that also scales with the user's font-size setting. Text sizes go in sp, everything else in dp.
**dp**: px = dp × density, where density is 1.0 at 160 dpi (mdpi), 2.0 at xhdpi and 3.0 at xxhdpi. The user's Display size setting changes it too.
**sp**: only for textSize. Text in dp ignores the accessibility font scale.
**px**: only for drawing code inside a custom View, never in layout XML. Do not convert by hand: use TypedValue.applyDimension or getDimensionPixelSize; font scaling is non-linear since Android 14 (details in the accessibility topic).
A style is a set of attribute values for one view; a theme is a set applied to an app, Activity or view subtree, and it also defines theme attributes such as colorPrimary that any view can look up with ?attr/.
**style="@style/…" on a view affects only that view, not its children.
android:theme** on <application>, <activity> or a view cascades to everything inflated under it, through a ContextThemeWrapper.
**?attr/colorPrimary** means "whatever the current theme says" and is resolved at inflation; @color/blue is a fixed value. Theme attributes are what let dark mode and a re-themed subtree work without editing layouts.
Day/night: inherit from Theme.Material3.DayNight and put overrides in values-night/; AppCompatDelegate.setDefaultNightMode() switches at runtime and recreates the Activity.
findViewById looks a view up at runtime and can return null or the wrong type; ViewBinding generates a class per layout with a typed field for every android:id (nullable only if the view is missing from some variants); DataBinding adds expressions and two-way binding in XML.
ViewBinding: buildFeatures { viewBinding = true }; activity_main.xml becomes ActivityMainBinding with inflate(), bind() and root. No annotation processor, almost free at build time. The default for View-based code.
DataBinding: needs a <layout> wrapper and the kotlin-kapt plugin, slows the build and moves logic into XML, where it is hard to test and debug. Mostly found in legacy code; new screens use ViewBinding or Compose.
**findViewById**: still what bind() calls inside. Fine in a small custom View, error-prone across a whole screen.
RecyclerView creates only enough rows to fill the screen plus a few spare ones and reuses them while scrolling: onCreateViewHolder inflates a new row when no recycled row of that view type is available, and onBindViewHolder fills an existing row with the data for a position.
Adapter: turns data into rows: item count, view type, create and bind.
ViewHolder: keeps a row's view and its child references, so binding never calls findViewById.
LayoutManager: measures and positions rows and decides which ones left the screen (LinearLayoutManager, GridLayoutManager, StaggeredGridLayoutManager).
Recycling: a row that scrolls off goes first to a small cache keyed by position (2 by default, reused without rebinding), then to the RecycledViewPool, keyed by view type (5 per type by default), where it must be rebound.
**Why not ListView**: ListView left the ViewHolder pattern optional, scrolled only vertically and had no item animations or notifyItem* updates. RecyclerView makes the holder mandatory and splits layout, decoration and animation into pluggable parts.
Bind runs far more often than create, so bind must be cheap and must set every view on every call.
They keep list decoration and gestures out of the row layout: ItemDecoration adds spacing and dividers, ItemTouchHelper adds swipe-to-dismiss and drag-to-reorder, and SnapHelper makes a fling stop with an item aligned.
**ItemDecoration**: getItemOffsets() adds space around a row, onDraw() and onDrawOver() paint under or over rows. MaterialDividerItemDecoration is ready-made. Margins in the row layout instead double up between items and cannot treat the first and last row differently.
**ItemTouchHelper**: takes a SimpleCallback(dragDirs, swipeDirs): onMove reorders, onSwiped removes. Change the data there, or the swiped row comes back.
**SnapHelper**: LinearSnapHelper centres the nearest item; PagerSnapHelper moves one item per fling, like a pager.
It builds a complex screen as one flat level instead of nested LinearLayouts and RelativeLayouts. That matters because weighted LinearLayout and RelativeLayout measure children twice, and the cost multiplies with every level of nesting.
Why flat matters
LinearLayout with layout_weight measures weighted children twice; RelativeLayout always does. Three such levels nested can measure a leaf eight times per pass.
ConstraintLayout can also measure a child more than once, but with one level there is nothing to multiply.
The tools
**0dp (MATCH_CONSTRAINT)**: fill the space between the two constraints on that axis. match_parent on a child ignores constraints: the classic "the text runs under the button" bug. wrap_content with app:layout_constrainedWidth="true" wraps but never crosses a constraint.
Chain: views constrained to each other in both directions; chainStyle (spread, spread_inside, packed) and layout_constraintHorizontal_weight replace LinearLayout weights.
Guideline: an invisible line at a dp offset or a percentage.
Barrier: follows the widest of several views, so a label column survives translation into a longer language.
**layout_constraintDimensionRatio="16:9"**: sizes a 0dp side from the other one.
If you nest a ConstraintLayout only to get weights, you want a chain, a guideline or a barrier.
LayoutInflater walks the compiled XML on the main thread and creates a View object for every tag; <include> reuses another layout file, <merge> removes that file's extra root, and ViewStub postpones inflating a part of the screen until it is needed.
Where the cost lands: inflation runs inside setContentView or onCreateView, before the first frame. Every tag is a constructor call (reflection for custom views) plus attribute parsing, so view count and depth turn directly into screen-open time.
**<include>**: inlines another layout. layout_* attributes on the tag override the included root's only when both layout_width and layout_height are set.
**<merge>**: a root that is not a View: its children attach straight to the including parent, so the wrapper level disappears. Add tools:parentTag so the preview knows how to arrange them.
**ViewStub**: a 0×0 placeholder that draws nothing. inflate() (or making it visible) inflates the real layout and replaces the stub, so a second inflate() throws. Good for an error panel or an empty state.
**tools: attributes**: tools:text, tools:listitem; design-time only and stripped from the build.
**AsyncLayoutInflater** moves inflation off the main thread, but cannot handle layouts whose views create a Handler; flattening the layout usually helps more.
A Fragment outlives its view: after onDestroyView it stays on the back stack and its binding field keeps the dead view tree alive. Null the binding in onDestroyView and collect on viewLifecycleOwner, not lifecycleScope.
Fix the field: make it a nullable _binding, set it in onViewCreated (or onCreateView) and set it to null in onDestroyView. Read it through a non-null getter only between those two callbacks.
Fix the collector: launch on viewLifecycleOwner.lifecycleScope with repeatOnLifecycle(Lifecycle.State.STARTED). The Fragment's own lifecycleScope lives until the Fragment is destroyed, long after the view.
**bind(view), not inflate()**: the Fragment(R.layout.…) constructor has already inflated the layout.
Why an Activity is different: an Activity and its views die together, so a lateinit binding there does not leak.
A View is constructed (from XML or code), attached to a window, goes through measure, layout and draw on every frame that needs it, and is finally detached. onAttachedToWindow and onDetachedFromWindow are where you start and stop anything the view owns.
**Constructor, then onFinishInflate**: attributes are read; onFinishInflate runs once all children from XML exist, a good place for a custom ViewGroup to find them.
**onAttachedToWindow: the view is in a window: register listeners, start animations. Its size is still 0 here.
onMeasure → onSizeChanged → onLayout → onDraw**: driven by ViewRootImpl.performTraversals() when Choreographer delivers the next vsync. Measure and layout run only after requestLayout(), drawing after invalidate(); onSizeChanged fires only when the size actually changed.
Rendering: with hardware acceleration onDraw records commands into the view's RenderNode; the RenderThread sends them to the GPU. A view that was not invalidated is not re-recorded, its old display list is replayed.
**onDetachedFromWindow**: removed from the window or the Activity is destroyed: stop animators, unregister listeners, cancel jobs. A RecyclerView row detaches and reattaches as it is recycled, so this pair can run many times for one object.
In onCreate the view tree is only inflated; measure and layout run later, in the first frame after the window is attached, so until then every size is 0. Wait for layout with doOnLayout instead of guessing.
**doOnLayout { } (core-ktx): runs at once if the view is already laid out, otherwise once after the next layout. The usual answer.
doOnPreDraw { }: after layout, right before the frame is drawn; useful for setting up a transition.
view.post { }: works because the runnable runs after the first traversal, but it is ordering by accident and misses later size changes.
addOnLayoutChangeListener / OnGlobalLayoutListener: fire after every layout pass; remove the global one when done, or it runs for the life of the view.
In a custom View** use onSizeChanged. The size can change again on rotation, the keyboard, multi-window or a fold, so code that reads it once is fragile.
The parent measures each child with a MeasureSpec, positions it with layout(), and then each view paints itself in onDraw. Call invalidate() when only the pixels change and requestLayout() when the size or position may change.
Measure
A MeasureSpec packs a mode and a size into one Int: EXACTLY (match_parent in an exact parent, or a fixed dp), AT_MOST (wrap_content, up to this size), UNSPECIFIED (as big as you like, e.g. inside a ScrollView).
onMeasure must call setMeasuredDimension(), or the framework throws IllegalStateException. resolveSize() applies the mode for you.
It can run several times per pass, so keep it cheap and free of side effects.
Layout and draw
onLayout matters only in a ViewGroup: call child.layout(l, t, r, b) with the sizes from measure. Never call requestLayout() from inside it.
onDraw runs on every frame the view is invalid, up to 120 times a second. Keep Paint, Path and RectF as fields; allocating there means garbage on every frame (Lint warns with DrawAllocation).
Honour padding yourself: the canvas starts at 0,0, not at paddingLeft.
invalidate vs requestLayout
invalidate() is for redraw only: progress, colour, selection.
requestLayout() walks up to the root and schedules measure and layout for the branch, then draw. Calling it from a setter that an animator drives every frame burns the frame budget for nothing.
Declare the attributes in <declare-styleable> and read them in the constructor with obtainStyledAttributes; for state, override onSaveInstanceState and onRestoreInstanceState with a BaseSavedState subclass. The view must have an android:id, or nothing is saved.
Reading attributes: context.obtainStyledAttributes(attrs, R.styleable.RatingView, defStyleAttr, 0) returns a TypedArray; recycle it, which use { } from core-ktx does for you.
Constructors: @JvmOverloads generates the (Context, AttributeSet) constructor that XML inflation calls. When you extend a widget such as Button, pass its default style attribute instead of 0, or you lose its default look.
Saving: wrap super.onSaveInstanceState() as the superState; dropping it loses the base class's state.
Restoring: unwrap your SavedState, pass superState up, then read your fields.
The id rule: state is stored in a SparseArray keyed by id: no id means no state, and two views with the same id overwrite each other's slot.
What belongs here: UI-only state like an expanded flag. Data the screen shows belongs in the ViewModel.
When frames are produced off the main thread at a high rate: video, camera preview, a game or a map renderer. SurfaceView gives the producer its own surface that the system composites; TextureView puts the frames into the normal view hierarchy as a GPU texture.
A normal View draws in onDraw on the main thread into the window's single surface. Fine for UI, a bottleneck for continuous rendering.
**SurfaceView**: a separate Surface composited by SurfaceFlinger, often as a hardware overlay. Best battery life and latency, required for DRM-protected video, and drawable from any thread (SurfaceHolder.lockCanvas(), OpenGL, Vulkan). Since Android 7.0 it moves in sync with the views around it, but transforms and translucency are still limited.
**TextureView: behaves like any view: alpha, rotation, clipping, scrolling inside a list. The cost is an extra GPU copy, more memory and a frame or more of added latency; it needs hardware acceleration.
Default choice**: SurfaceView; Media3 PlayerView and CameraX PreviewView use it by default. Switch to TextureView only when the content must animate, overlap or be transformed like a view.
Views are animated with property animators (ValueAnimator, ObjectAnimator, view.animate()), Transitions, MotionLayout and spring/fling animations. Only translation, scale, rotation and alpha skip measure and layout; animating layoutParams.height re-lays out the branch every frame.
The APIs
Property animators: ValueAnimator, ObjectAnimator, view.animate(). They change real properties, so the view is where it looks and touches land there.
View animations (res/anim, TranslateAnimation) are legacy: they move only the pixels and leave the touch area behind.
Transitions: TransitionManager.beginDelayedTransition(root) animates the layout changes you make right after it; also shared-element transitions between screens.
**MotionLayout: a ConstraintLayout that animates between two constraint sets, optionally driven by a swipe or a scroll.
Physics**: SpringAnimation and FlingAnimation from androidx.dynamicanimation, interruptible and velocity-aware.
Keeping it on the cheap path
Prefer the four RenderNode properties (translation, scale, rotation, alpha): they only change how the recorded view is drawn, so the RenderThread applies them without measure or layout. Animate size only when the layout really has to change.
When it does, beginDelayedTransition measures the end state once and animates the bounds, instead of relayout on every frame.
withLayer() renders a complex view into a hardware layer for the duration, so a fade does not redraw its children every frame.
notifyDataSetChanged() says "everything changed": every visible row is rebound, nothing animates and scroll anchoring is lost. ListAdapter runs DiffUtil between the old and new list on a background thread and dispatches exact insert, remove, move and change events.
**areItemsTheSame: is it the same entity? Compare ids only. Comparing whole objects turns every edit into remove plus insert, so the row blinks.
areContentsTheSame**: does it look the same? Called only for the same item; a data class equals usually works if the model is immutable.
Payloads: getChangePayload returns what changed; the three-argument onBindViewHolder(holder, position, payloads) then updates only that view, with no crossfade. Empty payloads means a full bind.
**submitList needs a new list. Mutating the list you already submitted and submitting it again compares a list with itself and nothing updates.
Without ListAdapter**: notifyItemChanged(position, payload), notifyItemInserted and friends do the same for a single known change.
toggleLike copies the list but mutates the shared Post in place, so the list the adapter already holds changes too. StateFlow sees an equal list and does not emit, and the row shows the new state only on its next rebind.
**toMutableList() is a shallow copy**: the new list holds the same Post objects as the submitted one. Changing liked rewrites the "before" state as well, so there is nothing to compare against.
Two layers swallow the change: MutableStateFlow drops a value that equals the current one, and two lists with the same elements are equal. With LiveData the list would arrive, but areContentsTheSame would compare each object with itself and DiffUtil would dispatch nothing.
Why scrolling "fixes" it: a rebind reads the mutated object, so the heart appears once the row leaves the screen and comes back.
The fix: make the UI model immutable (val fields) and build a new item with copy() inside update { }. The new list differs, StateFlow emits, and DiffUtil reports exactly one change.
Catch it next time: no var in models handed to a ListAdapter (a review or Detekt rule), and a ViewModel test that asserts posts emits a new value after toggleLike.
The holder is a recycled row that still shows whatever the previous item set, so bind must assign every view on every path. Here the badge is never hidden again, and the photo callback writes into a row that may already show another item.
**if without else**: badge.isVisible = item.isUrgent sets both states.
Async result into a recycled view: the image arrives after the row was rebound for a different position. Coil or Glide tie the request to the ImageView and cancel the previous one; with your own loader, clear the image in bind and cancel in onViewRecycled.
**Captured position**: the lambda keeps the position from bind time, which is wrong after an insert or move. Set the listener once in onCreateViewHolder and read bindingAdapterPosition at click time.
A new listener per bind: an allocation on every scroll step for nothing.
Running animations: cancel them in onViewRecycled, or a half-finished fade shows up on a new item.
Bind assigns isChecked while the recycled row still holds the previous item's listener, and setChecked fires it synchronously. The previous task is saved with the new task's state, so scrolling silently rewrites data.
The listener outlives its bind: the lambda captured the task from this holder's last bind. When the row is recycled for another position, that old lambda is still attached at the moment isChecked changes.
**CompoundButton does not know who changed it**: OnCheckedChangeListener fires for any real state change, from code or from a tap. That is why only some rows break: those where the old and the new done differ.
The fix: call setOnCheckedChangeListener(null) before assigning isChecked, then attach the new listener. An alternative is one listener set in onCreateViewHolder that reads bindingAdapterPosition and ignores calls where checked == item.done.
The same trap: any view whose setter fires a callback: SwitchMaterial, RadioGroup, Slider, a TextWatcher on an EditText inside a row.
Catch it next time: an instrumented test that scrolls a list with mixed states and asserts the data did not change; in review, flag "set state, then set listener" in every bind.
Return a different getItemViewType for each kind of row and create the matching ViewHolder in onCreateViewHolder. Put separate sections such as a header or a load-more footer into their own adapters joined by ConcatAdapter.
The pool is per view type, so a header holder is never handed to a card. The layout id makes a unique, readable view type.
**Sealed interface plus an exhaustive when: a new row type fails to compile until it is handled.
ConcatAdapter** removes the position - 1 arithmetic: each adapter sees its own positions. In a holder, bindingAdapterPosition is the position in its own adapter, absoluteAdapterPosition the position in the whole list.
DiffUtil: areItemsTheSame must compare the class as well as the id, since an ad and an article can share an id.
Many types across teams: delegate adapters (AdapterDelegates, Epoxy, Groupie) apply the same idea with one class per row type.
Measure first: a system trace in Perfetto or the Android Studio profiler shows which RecyclerView slices (RV OnBindView, RV CreateView, RV OnLayout) overflow the frame. Then go through the causes below in order.
Binding: no I/O, no heavy formatting (dates, text), no new listeners in onBindViewHolder. Prepare display-ready models in the ViewModel.
Images: load through Coil or Glide, sized to the view. Decoding a full-size bitmap on the main thread is the classic cause.
Inflation during scroll: many RV CreateView slices mean the pool runs dry: flatten row layouts, share one RecycledViewPool between nested carousels and set setInitialPrefetchItemCount on their horizontal LayoutManagers.
Layout: setHasFixedSize(true) when content changes never change the list's own size; avoid nested weights in rows; never put a RecyclerView inside a ScrollView, which inflates every row at once.
Full rebinds: notifyDataSetChanged() shows up as RV FullInvalidate; switch to ListAdapter.
Overdraw: a background on every layer costs GPU time on every frame.
Test a release build with a baseline profile on a mid-range phone; a debug build on a flagship tells you little.
Children of CoordinatorLayout react to each other through Behaviors, and the list reports its scroll through nested scrolling, so AppBarLayout can consume it and collapse first. RecyclerView and NestedScrollView support that; a plain ScrollView does not.
Nested scrolling
Before scrolling itself, the child calls dispatchNestedPreScroll; the parent can consume part of the distance, which is how the app bar collapses before the list moves.
After scrolling, dispatchNestedScroll passes on what was left unconsumed, which is how the bar expands once the list reaches its top. Flings go through the same pair.
app:layout_behavior="@string/appbar_scrolling_view_behavior" on the list places it below the app bar and moves it as the bar collapses.
Scroll flags on the AppBarLayout child
scroll|exitUntilCollapsed collapses down to the toolbar's height and stays there.
scroll|enterAlways reappears on any upward scroll; add snap to avoid half-open states.
Inside CollapsingToolbarLayout, layout_collapseMode="parallax" or "pin" decides how each child moves.
Pitfalls
nestedScrollingEnabled="false" on the list, or a list inside a non-nested container, silently disables the collapse.
A ComposeView scrolling inside needs rememberNestedScrollInteropConnection() to report its scroll.
Custom reactions (a FAB that moves above a Snackbar) are a Behavior attached via CoordinatorLayout.AttachedBehavior or app:layout_behavior.
A MotionEvent travels down through dispatchTouchEvent: each ViewGroup may claim it in onInterceptTouchEvent, otherwise it goes to the child under the finger, and unhandled events bubble back up through onTouchEvent. Whoever returns true for ACTION_DOWN gets the rest of the gesture.
The rules
**ACTION_DOWN picks the target.** A view that returns false for DOWN receives nothing more from that gesture.
A parent can still intercept later, usually on ACTION_MOVE. Once onInterceptTouchEvent returns true, the child receives ACTION_CANCEL and the remaining events go to the parent's onTouchEvent.
The child can veto with parent.requestDisallowInterceptTouchEvent(true); ancestors stop intercepting until the gesture ends.
**OnTouchListener runs before onTouchEvent**; returning true from it skips onTouchEvent, including click handling.
Touch slop: decide only after the finger has moved more than ViewConfiguration.scaledTouchSlop and the direction is clear. Intercepting on DOWN breaks every click inside.
The typical conflict
A horizontal carousel inside a vertical list: the outer RecyclerView intercepts as soon as vertical movement passes the slop, so a diagonal swipe scrolls the wrong list.
Fix it in a wrapper around the inner list: on DOWN disallow interception, on MOVE allow it again if the gesture is mostly vertical. The ViewPager2 samples include a similar NestedScrollableHost wrapper.
Views are not thread-safe, and the check lives in ViewRootImpl.checkThread(), which runs only once the view is attached to a window. In onCreate there is no ViewRootImpl yet, so the write slips through by luck; after attach it throws CalledFromWrongThreadException.
What triggers it: setText calls requestLayout() and invalidate(), and both walk up to ViewRootImpl, which checks the thread.
The rule: touch a view only on the thread that created its ViewRootImpl, which in practice is the main thread.
**The onCreate case is a race, not a permission. If the thread runs after the window is attached, it crashes; if it runs during a traversal, it can corrupt state with no exception at all.
The fix**: do the work off the main thread and deliver the result on it: lifecycleScope.launch { val text = withContext(Dispatchers.IO) { repository.load() }; binding.status.text = text }. view.post { } and runOnUiThread { } also work.
**postInvalidate() is the one redraw request that is safe from any thread.
Real off-thread drawing** is what SurfaceView and TextureView exist for. For heavy text, PrecomputedTextCompat computes the text layout on a background thread and hands the result to the main one.
Apps targeting SDK 35 run edge-to-edge on Android 15 whether they want to or not: the window extends under the status and navigation bars, setStatusBarColor is ignored, and every view at an edge must apply WindowInsets itself.
What changed
Edge-to-edge is enforced on Android 15+ for targetSdk 35. Call enableEdgeToEdge() so older versions behave the same way and you test one layout.
window.statusBarColor and navigationBarColor are deprecated and do nothing; control icon contrast with WindowInsetsControllerCompat.isAppearanceLightStatusBars.
The opt-out attribute windowOptOutEdgeToEdgeEnforcement works only on Android 15; apps targeting SDK 36 (Android 16) cannot opt out at all.
Applying insets
Toolbar: top padding from systemBars() combined with displayCutout().
List: bottom padding plus clipToPadding = false, so rows scroll under the navigation bar but the last one stops above it.
Keyboard: with edge-to-edge, adjustResize no longer shrinks your content for you; read WindowInsetsCompat.Type.ime() and pad the input yourself. WindowInsetsAnimationCompat.Callback moves it in sync with the keyboard animation.
Return the insets from the listener; returning WindowInsetsCompat.CONSUMED starves every view after it.
Material components such as BottomNavigationView apply insets themselves; AppBarLayout needs android:fitsSystemWindows="true".