Custom Views, Touch, Drawing and View Animation

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

The View system covered on Views, XML Layouts, View Binding and Material Components and RecyclerView and Lists gets most screens built from stock widgets, but a hiking app needs at least one piece of UI no stock widget draws: a trail’s elevation profile as a filled line chart. This page builds that view, TrailMate’s ElevationProfileView, from scratch — custom attributes, measurement, drawing, and multi-touch pan/zoom — then covers the View system’s animation surface end to end: property animation on plain views, AnimatedVectorDrawable, MotionLayout for a coordinated multi-view motion (a collapsing header), and CoordinatorLayout/AppBarLayout scrolling behaviors. It targets this section’s baseline — Android 17 (API 37), AndroidX Constraintlayout 2.2.1 (which ships MotionLayout), Material Components for Android (MDC) 1.13.0 (which ships CoordinatorLayout/AppBarLayout) — and builds on Views, XML Layouts, View Binding and Material Components, whose measure/layout/draw pass overview this page assumes. Compose’s equivalent animation and gesture APIs are covered separately on Compose Animation and Gestures; this page is about the View system only.

ElevationProfileView: a Custom View from Scratch

ElevationProfileView draws TrailDetailScreen’s elevation-vs-distance chart for a `Trail: a filled area under a line, with a highlighted point wherever the user drags a finger across it. None of TextView, ImageView or any Material widget can draw this, so it subclasses View directly and does its own measuring and drawing.

Declaring Custom Attributes

A custom view that needs to be configurable from XML — here, the line and fill colors, so the same view can be themed per difficulty level without subclassing it — declares a <declare-styleable> in res/values/attrs.xml:

<!-- feature/trails/src/main/res/values/attrs.xml -->
<resources>
    <declare-styleable name="ElevationProfileView">
        <attr name="lineColor" format="color" />
        <attr name="fillColor" format="color" />
        <attr name="lineWidth" format="dimension" />
    </declare-styleable>
</resources>

The view reads those attributes back inside an init block using the withStyledAttributes Kotlin extension (androidx.core.content.withStyledAttributes), which wraps Context.obtainStyledAttributes()/TypedArray.recycle() in a single call so the TypedArray cannot be leaked by an exception thrown while reading it:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/ElevationProfileView.kt
class ElevationProfileView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {

    private var lineColor: Int = Color.BLUE
    private var fillColor: Int = Color.LTGRAY
    private var lineWidthPx: Float = 4f

    init {
        attrs?.let {
            context.withStyledAttributes(it, R.styleable.ElevationProfileView) {
                lineColor = getColor(R.styleable.ElevationProfileView_lineColor, lineColor)
                fillColor = getColor(R.styleable.ElevationProfileView_fillColor, fillColor)
                lineWidthPx = getDimension(R.styleable.ElevationProfileView_lineWidth, lineWidthPx)
            }
        }
    }
}

The three-argument constructor plus @JvmOverloads is the standard shape for any custom view: the framework calls the two- or three-argument overload depending on whether the view is inflated from XML with a style attribute, and @JvmOverloads generates all three from one Kotlin function instead of writing three constructors by hand. attrs is nullable because a view instantiated purely from code (ElevationProfileView(context), with no XML attributes to read) legitimately has none. See Android Developers — Create a Custom View.

Measuring: onMeasure and MeasureSpec

ElevationProfileView wants a fixed aspect ratio — its height should track a fraction of whatever width it is given, so the chart stays legible on both phones and tablets — which only onMeasure can express, since XML layout_width/layout_height alone cannot make one dimension depend on the other:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/ElevationProfileView.kt
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val width = MeasureSpec.getSize(widthMeasureSpec)
    val desiredHeight = (width * HEIGHT_RATIO).toInt()
    val height = when (MeasureSpec.getMode(heightMeasureSpec)) {
        MeasureSpec.EXACTLY -> MeasureSpec.getSize(heightMeasureSpec)
        MeasureSpec.AT_MOST -> minOf(desiredHeight, MeasureSpec.getSize(heightMeasureSpec))
        else -> desiredHeight // MeasureSpec.UNSPECIFIED
    }
    setMeasuredDimension(width, height)
}

private companion object {
    const val HEIGHT_RATIO = 0.4f
}

Every custom view that overrides onMeasure must call setMeasuredDimension() before returning — the base View.onMeasure() calls it with a default size derived from the background drawable’s minimum size, which is rarely what a custom view wants, and skipping the call entirely throws IllegalStateException at layout time. The three MeasureSpec modes read above — EXACTLY (the parent has already fixed this dimension, for example layout_height="120dp" or match_parent against a fixed-size parent), AT_MOST (the view may be up to this size, typically from wrap_content inside a constrained parent), and UNSPECIFIED (no constraint at all, rare outside a ScrollView or a measurement pass done for other purposes) — are exactly the ones introduced on Views, XML Layouts, View Binding and Material Components's measure pass overview; a custom view is the first place most Android code actually has to read and act on them directly rather than relying on a stock ViewGroup to interpret them. See Android Developers — View.MeasureSpec.

onSizeChanged: Caching Layout-Dependent State

onSizeChanged(w, h, oldw, oldh) runs once measurement and layout have settled on a final size, and only when that size actually changes — the right place to precompute anything that depends on pixel dimensions but not on the trail data itself, such as the Path object the chart is drawn from, so onDraw never has to rebuild it on every frame:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/ElevationProfileView.kt
private val profilePath = Path()
private val fillPath = Path()

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    rebuildPaths(w, h)
}

private fun rebuildPaths(w: Int, h: Int) {
    profilePath.reset()
    fillPath.reset()
    val points = elevationPoints
    if (points.isEmpty()) return
    val maxElevation = points.maxOf { it.elevationMeters }
    val minElevation = points.minOf { it.elevationMeters }
    val range = (maxElevation - minElevation).coerceAtLeast(1.0)
    points.forEachIndexed { index, point ->
        val x = w * (point.distanceKm / points.last().distanceKm).toFloat()
        val y = h - h * ((point.elevationMeters - minElevation) / range).toFloat()
        if (index == 0) profilePath.moveTo(x, y) else profilePath.lineTo(x, y)
    }
    fillPath.set(profilePath)
    fillPath.lineTo(w.toFloat(), h.toFloat())
    fillPath.lineTo(0f, h.toFloat())
    fillPath.close()
}

Drawing: Canvas, Paint and Path

onDraw(canvas) is called every time the view needs to repaint, and should do as little work as possible beyond issuing draw calls — both Paint objects and the two Path instances above are fields created once, never inside onDraw itself, since allocating there runs on every single frame the view is visible and directly causes dropped frames under scroll or animation:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/ElevationProfileView.kt
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    style = Paint.Style.STROKE
    strokeWidth = lineWidthPx
    color = lineColor
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    style = Paint.Style.FILL
    color = fillColor
}
private val markerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    style = Paint.Style.FILL
    color = lineColor
}

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    canvas.drawPath(fillPath, fillPaint)
    canvas.drawPath(profilePath, linePaint)
    highlightedPoint?.let { canvas.drawCircle(it.x, it.y, MARKER_RADIUS_PX, markerPaint) }
}

private companion object {
    const val MARKER_RADIUS_PX = 10f
}

Paint.ANTI_ALIAS_FLAG smooths the line and marker edges; Paint.Style.STROKE vs. Paint.Style.FILL decides whether a shape is outlined or filled solid, which is why the same profile line is traced twice into two different Path objects above — fillPath closes the shape down to the view’s bottom edge so it can be filled, while profilePath stays an open line so STROKE draws only the line itself, not its enclosing rectangle. See Android Developers — Custom Drawing, Android Developers — Canvas, Android Developers — Paint and Android Developers — Path.

invalidate() vs. requestLayout()

ElevationProfileView needs exactly one of these two calls depending on what actually changed, and confusing them is the single most common custom-view performance mistake:

Call Triggers When ElevationProfileView needs it

invalidate()

A redraw only (onDraw runs again on the next frame); size and position are untouched.

The user drags a finger and highlightedPoint moves — the chart’s shape and the view’s bounds do not change, only what is painted inside them.

requestLayout()

A new measure + layout pass for this view (and, if its size changes, its parent), then a redraw.

A new Trail with a different elevation profile is bound and the view’s aspect-ratio-driven height in onMeasure could plausibly change — though for ElevationProfileView specifically, only rebuildPaths() plus invalidate() is actually needed, since the ratio depends on width, not on the data.

Calling requestLayout() when only invalidate() was needed forces an unnecessary measure/layout pass on every update — expensive on a deep hierarchy — while calling only invalidate() when a size-affecting property changed leaves the view drawing stale content at its old bounds until something else happens to trigger a layout pass. setHighlightedPoint() below only invalidates:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/ElevationProfileView.kt
fun setHighlightedPoint(point: PointF?) {
    highlightedPoint = point
    invalidate()
}

fun setElevationPoints(points: List<ElevationPoint>) {
    elevationPoints = points
    rebuildPaths(width, height)
    invalidate() // shape changed, but this view's own size did not
}

Touch: onTouchEvent, GestureDetector and ScaleGestureDetector

ElevationProfileView reports the elevation under whichever point the user is dragging, and also lets a two-finger pinch zoom the visible distance range. Handling both gestures by hand inside onTouchEvent — tracking MotionEvent.ACTION_DOWN/ACTION_MOVE/ACTION_UP, telling a single-finger drag apart from a pinch, computing velocity for a fling — is exactly the boilerplate GestureDetector and ScaleGestureDetector exist to absorb:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/ElevationProfileView.kt
private val gestureDetector = GestureDetectorCompat(
    context,
    object : GestureDetector.SimpleOnGestureListener() {
        override fun onScroll(
            e1: MotionEvent?,
            e2: MotionEvent,
            distanceX: Float,
            distanceY: Float,
        ): Boolean {
            updateHighlightFromTouch(e2.x)
            return true
        }
    },
)

private val scaleGestureDetector = ScaleGestureDetector(
    context,
    object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
        override fun onScale(detector: ScaleGestureDetector): Boolean {
            zoomFactor = (zoomFactor * detector.scaleFactor).coerceIn(MIN_ZOOM, MAX_ZOOM)
            rebuildPaths(width, height)
            invalidate()
            return true
        }
    },
)

override fun onTouchEvent(event: MotionEvent): Boolean {
    scaleGestureDetector.onTouchEvent(event)
    gestureDetector.onTouchEvent(event)
    if (event.action == MotionEvent.ACTION_UP) {
        setHighlightedPoint(null)
    }
    return true
}

private companion object {
    const val MIN_ZOOM = 1f
    const val MAX_ZOOM = 4f
}

Both detectors are handed every MotionEvent unconditionally and each decides for itself whether the event sequence matches the gesture it recognizes — GestureDetector.SimpleOnGestureListener is a no-op adapter that lets an override implement only onScroll (drag) instead of the full OnGestureListener interface, and ScaleGestureDetector.SimpleOnScaleGestureListener does the same for onScale. onTouchEvent must return true for a View to keep receiving the rest of a gesture’s events (ACTION_MOVE/ACTION_UP) after the initial ACTION_DOWN; returning false there tells the framework this view is not interested, and no further events in that sequence arrive at all. See Android Developers — Detect Common Gestures, Android Developers — GestureDetector and Android Developers — ScaleGestureDetector.

Accessibility for Custom Views

A stock TextView or Button announces its role, text and state to TalkBack for free; a custom view that draws everything itself, like ElevationProfileView, announces nothing unless it is told to. The minimum viable fix is a meaningful contentDescription set from whatever summary data the view has (TrailMate sets it once per bound trail, e.g. "Elevation profile, 320 meters of total ascent"), but a view that also acts like a control — ElevationProfileView’s drag-to-inspect behavior functions like a `SeekBar — should expose that through an AccessibilityDelegateCompat instead of leaving TalkBack with only a static description:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/ElevationProfileView.kt
ViewCompat.setAccessibilityDelegate(this, object : AccessibilityDelegateCompat() {
    override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
        super.onInitializeAccessibilityNodeInfo(host, info)
        info.className = SeekBar::class.java.name
        info.contentDescription = elevationSummaryDescription()
    }
})

Reporting className = SeekBar::class.java.name tells TalkBack to treat the view as an adjustable control for announcement purposes even though it is not one; a real "adjustable by discrete steps" custom view would go further and set AccessibilityNodeInfoCompat.RangeInfo so TalkBack can announce the current value, not just the class. This is the same underlying AccessibilityNodeInfo tree that Compose’s semantics tree (Accessibility (Views and Compose)) is built on top of for composables; a custom View is simply the one place in the View system where nothing populates that tree automatically. See Android Developers — Make Apps More Accessible.

Compound Views

Not every reusable piece of UI needs custom drawing. A compound view groups several existing widgets behind one reusable class without overriding onDraw at all — TrailMate’s TrailStatChip (an icon plus a label plus a value, used three times on TrailDetailFragment for length, difficulty and elevation gain) extends LinearLayout, inflates its own child layout in an init block, and exposes a small typed API instead of requiring the caller to know its internal view ids:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailStatChip.kt
class TrailStatChip @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : LinearLayout(context, attrs) {

    private val binding = ViewTrailStatChipBinding.inflate(LayoutInflater.from(context), this)

    init {
        orientation = HORIZONTAL
        context.withStyledAttributes(attrs, R.styleable.TrailStatChip) {
            binding.chipLabel.text = getString(R.styleable.TrailStatChip_chipLabel)
            binding.chipIcon.setImageResource(
                getResourceId(R.styleable.TrailStatChip_chipIcon, 0),
            )
        }
    }

    fun setValue(value: String) {
        binding.chipValue.text = value
    }
}

A compound view like TrailStatChip is the right choice whenever a group of stock widgets is repeated with the same internal structure across several screens; ElevationProfileView, by contrast, needs a compound view’s approach to be impossible in the first place, since nothing in the stock widget catalog can draw an arbitrary filled line chart. Both are declared and configured from XML the same way — a custom <declare-styleable>, read in init — which is why the two techniques are covered together rather than as unrelated topics. See Android Developers — Create a Custom View (Compound views section).

Hardware Acceleration

Since Android 3.0, the View system draws through a hardware-accelerated Canvas by default (declared per-window or per-view with android:hardwareAccelerated, and per-view programmatically with View.setLayerType(LAYER_TYPE_HARDWARE, …​)): most Canvas/Paint operations — including everything ElevationProfileView.onDraw() above does — are translated into GPU draw calls rather than executed on the CPU, which is what makes animating a view’s properties (below) smooth even on a moderately complex layout. Not every Canvas operation has a hardware-accelerated equivalent; a handful of Paint/Canvas APIs (certain Xfermode blend modes, Canvas.clipPath() with certain path shapes on older API levels) either silently fall back to software rendering for the affected draw call or require the view to opt into a software layer (LAYER_TYPE_SOFTWARE) to render correctly. View.setLayerType(LAYER_TYPE_HARDWARE, null) is also the standard way to make an in-flight ObjectAnimator/ViewPropertyAnimator animation on a complex view hierarchy composite as a single cached GPU layer for its duration, trading extra GPU memory for smoother frame rates while several properties animate at once. See Android Developers — Hardware Acceleration.

Property Animation: ViewPropertyAnimator, ObjectAnimator, ValueAnimator and AnimatorSet

TrailMate fades in the elevation-profile marker described above, and animates a "trail added to favorites" heart icon, using the property animation system (android.animation), the View system’s general-purpose animation framework — distinct from the older view animation system (android.view.animation, Animation/AnimationSet, which only ever repaints a view’s drawn output without changing its actual layout properties, and is mentioned here only as the legacy alternative property animation replaced for anything beyond a handful of simple, fixed XML transitions).

API When to reach for it

ViewPropertyAnimator (view.animate())

Animating one or a few of a View’s own properties (`alpha, translationX/Y, scaleX/Y, rotation) with the least code; internally batches multiple property animations into a single, more efficient render pass.

ObjectAnimator

Animating any property — a View property by name, or a property on an arbitrary object with matching getter/setter methods — with full control over duration, repeat behavior, and listeners.

ValueAnimator

Animating a plain value over time with no target object at all; the caller reads the current value from an AnimatorUpdateListener on every frame and decides what to do with it (used below for ElevationProfileView’s own reveal animation, since a `Path’s draw extent has no getter/setter pair `ObjectAnimator could target directly).

AnimatorSet

Choreographing several Animator instances together — playTogether(), playSequentially(), or an explicit before()/after()/with() graph — as one logical animation with one start/end.

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailFragment.kt
// simplest case: ViewPropertyAnimator
binding.elevationProfileView.animate()
    .alpha(1f)
    .setDuration(300)
    .setInterpolator(DecelerateInterpolator())
    .start()

// ObjectAnimator + AnimatorSet: a "favorited" bounce, scale up then back down
val scaleUpX = ObjectAnimator.ofFloat(binding.favoriteToggle, View.SCALE_X, 1f, 1.3f)
val scaleUpY = ObjectAnimator.ofFloat(binding.favoriteToggle, View.SCALE_Y, 1f, 1.3f)
val scaleDownX = ObjectAnimator.ofFloat(binding.favoriteToggle, View.SCALE_X, 1.3f, 1f)
val scaleDownY = ObjectAnimator.ofFloat(binding.favoriteToggle, View.SCALE_Y, 1.3f, 1f)
AnimatorSet().apply {
    playTogether(scaleUpX, scaleUpY)
    play(scaleDownX).with(scaleDownY).after(scaleUpX)
    duration = 150
    interpolator = OvershootInterpolator()
    start()
}

// ValueAnimator: reveal ElevationProfileView's Path progressively left to right
ValueAnimator.ofFloat(0f, 1f).apply {
    duration = 500
    addUpdateListener { animator ->
        binding.elevationProfileView.setRevealFraction(animator.animatedValue as Float)
    }
    start()
}

An interpolator (DecelerateInterpolator, OvershootInterpolator, AccelerateDecelerateInterpolator, LinearInterpolator, or a custom TimeInterpolator) maps elapsed time fraction to animated value fraction — it is what makes an animation ease in, ease out, or overshoot past its target before settling, rather than moving at a constant rate for its whole duration. View.SCALE_X/View.SCALE_Y above are Property<View, Float> constants the framework ships specifically so ObjectAnimator can target a view’s own properties without the overhead and typo-risk of ObjectAnimator.ofFloat(view, "scaleX", …​)’s string-based reflection lookup. See Android Developers — Overview of Animation, Android Developers — Property Animation Overview and Android Developers — `Interpolator.

Layout Transitions

A ViewGroup can animate its own children automatically whenever one is added, removed, or has its visibility change, without any explicit Animator code at each call site, by setting android:animateLayoutChanges="true" in XML (backed by a default LayoutTransition) or assigning a customized LayoutTransition in code:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailFragment.kt
binding.notesContainer.layoutTransition = LayoutTransition().apply {
    enableTransitionType(LayoutTransition.CHANGING)
    setDuration(LayoutTransition.CHANGE_APPEARING, 200)
}

TrailMate uses this on the note-editing section of TrailDetailFragment: when the note TextInputLayout’s error text appears or disappears, the surrounding `ConstraintLayout siblings below it slide smoothly to their new position instead of jumping. LayoutTransition only animates a ViewGroup’s own children’s appearing, disappearing, and moving in response to a change already happening for another reason (a visibility flag flips, a child is added or removed) — it is not a general-purpose animation trigger like `ObjectAnimator above. See Android Developers — LayoutTransition.

AnimatedVectorDrawable

An AnimatedVectorDrawable animates the individual paths, groups, and properties of a VectorDrawable — morphing one path shape into another, rotating a group, animating a stroke’s trim range — entirely as a drawable, with no View subclass or Animator code needed at the call site. TrailMate’s favorite-toggle icon uses one to morph the outline heart into the filled heart instead of an abrupt drawable swap:

<!-- app/src/main/res/drawable/avd_favorite_toggle.xml -->
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:drawable="@drawable/vd_favorite_outline">
    <target
        android:name="heart_path"
        android:animation="@animator/favorite_path_morph" />
</animated-vector>
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailFragment.kt
(binding.favoriteToggle.drawable as? AnimatedVectorDrawable)?.start()

AnimatedVectorDrawableCompat extends the same XML format back to older API levels through AndroidX; declaring the animation as a drawable resource, rather than as Kotlin code driving a Path, keeps it reusable anywhere a plain Drawable is accepted (an ImageView, a menu item icon, a notification’s small icon on API levels that support it). See Android Developers — Vector Drawables Overview (AnimatedVectorDrawable section).

MotionLayout: a Collapsing Header

ConstraintLayout (introduced on Views, XML Layouts, View Binding and Material Components) positions views with one fixed set of constraints; MotionLayout, a ConstraintLayout subclass shipped in the same artifact, instead interpolates a whole view hierarchy between two constraint sets — a ConstraintSet for the start state and one for the end state — driven either by a scroll gesture, a touch drag, or a programmatic transition, animating every child’s position, size, alpha, and custom attributes together as one coordinated motion. TrailMate’s TrailDetailFragment uses MotionLayout for its collapsing photo header: fully expanded at the top of the screen, it shrinks the photo and moves the trail name into the toolbar as the user scrolls down.

A MotionScene XML resource, separate from the layout file, declares the two ConstraintSet states and the Transition between them:

<!-- app/src/main/res/xml/motion_scene_trail_header.xml -->
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <Transition
        app:constraintSetStart="@id/expanded"
        app:constraintSetEnd="@id/collapsed"
        app:duration="300">
        <OnSwipe
            app:touchAnchorId="@id/trail_photo"
            app:touchAnchorSide="bottom"
            app:dragDirection="dragUp" />
    </Transition>

    <ConstraintSet android:id="@+id/expanded">
        <Constraint
            android:id="@id/trail_photo"
            android:layout_width="0dp"
            android:layout_height="240dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent" />
        <Constraint
            android:id="@id/trail_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="24sp"
            app:layout_constraintTop_toBottomOf="@id/trail_photo"
            app:layout_constraintStart_toStartOf="parent" />
    </ConstraintSet>

    <ConstraintSet android:id="@+id/collapsed">
        <Constraint
            android:id="@id/trail_photo"
            android:layout_width="56dp"
            android:layout_height="56dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent" />
        <Constraint
            android:id="@id/trail_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="16sp"
            app:layout_constraintTop_toTopOf="@id/trail_photo"
            app:layout_constraintBottom_toBottomOf="@id/trail_photo"
            app:layout_constraintStart_toEndOf="@id/trail_photo" />
    </ConstraintSet>
</MotionScene>
<!-- fragment of app/src/main/res/layout/fragment_trail_detail.xml -->
<androidx.constraintlayout.motion.widget.MotionLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/trail_header"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:layoutDescription="@xml/motion_scene_trail_header">

    <ImageView
        android:id="@+id/trail_photo"
        android:layout_width="0dp"
        android:layout_height="240dp"
        android:scaleType="centerCrop" />

    <TextView
        android:id="@+id/trail_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</androidx.constraintlayout.motion.widget.MotionLayout>

Declaring the photo’s shrink and the title’s reflow as two static end states, with MotionLayout interpolating every attribute between them automatically, replaces what would otherwise be several coordinated ObjectAnimator/ValueAnimator calls kept in sync by hand — the same problem AnimatorSet solves for two or three properties becomes unwieldy well before a whole header layout is involved, which is MotionLayout’s reason to exist. `app:duration on Transition also accepts pure animation without a driving gesture, useful for motionLayout.transitionToState(R.id.collapsed) triggered from code instead of a swipe. See Android Developers — MotionLayout.

stateDiagram-v2 [*] --> Expanded Expanded --> Collapsed: "user scrolls up (OnSwipe drag)" Collapsed --> Expanded: "user scrolls down" Expanded: Expanded (photo 240dp, name 24sp below photo) Collapsed: Collapsed (photo 56dp, name 16sp beside photo)

CoordinatorLayout and AppBarLayout Behaviors

CoordinatorLayout, from com.google.android.material:material, solves a related but distinct problem: instead of interpolating one view hierarchy between two known states, it lets independent sibling views react to each other’s scroll and touch events through a pluggable CoordinatorLayout.Behavior attached per child, with neither sibling aware of the other directly. TrailMate’s alternative header treatment — a MaterialToolbar that shrinks and fades as the trail list scrolls, without `MotionLayout’s manual constraint sets — is built from `AppBarLayout’s own ready-made behavior instead:

<!-- app/src/main/res/layout/fragment_trail_list.xml -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="200dp"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <com.google.android.material.appbar.MaterialToolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin" />
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/trailRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

app:layout_behavior="@string/appbar_scrolling_view_behavior" attaches AppBarLayout.ScrollingViewBehavior to the RecyclerView, which is what tells CoordinatorLayout to forward the list’s scroll events up to the sibling AppBarLayout instead of only scrolling the list itself; layout_scrollFlags="scroll|exitUntilCollapsed" then tells the CollapsingToolbarLayout how to consume that forwarded scroll — shrinking until only the pinned toolbar remains, rather than scrolling fully off-screen. A custom CoordinatorLayout.Behavior<V> subclass, hooked up with app:layout_behavior="com.example.trailmate…​MyBehavior", is the extension point for a bespoke reaction — TrailMate’s "log a hike" floating action button uses one to slide itself out of the way when a Snackbar appears, reacting to the Snackbar’s own coordinator-aware layout change without either view referencing the other. `CoordinatorLayout and MotionLayout are not competitors for the same job: CoordinatorLayout is for independent siblings each reacting to shared scroll/touch input, while MotionLayout is for one hierarchy moving as a single, fully specified choreography between two states. See Android Developers — CoordinatorLayout, Android Developers — CoordinatorLayout.Behavior and Android Developers — AppBarLayout.

Activity and Fragment Shared-Element Transitions

A shared-element transition animates a view that visually represents the same content on both sides of a navigation — TrailMate’s trail photo, shown small in a list row and large on the detail screen — morphing its position, size, and (with changeImageTransform) its scale/crop directly from one to the other instead of the detail screen simply fading or sliding in over the old row. Both sides tag the shared view with a matching android:transitionName:

<!-- item_trail.xml -->
<ImageView android:id="@+id/trail_photo" android:transitionName="@string/transition_trail_photo" />

<!-- fragment_trail_detail.xml -->
<ImageView android:id="@+id/trail_photo" android:transitionName="@string/transition_trail_photo" />

For two activities, the caller supplies an ActivityOptions.makeSceneTransitionAnimation() bundle built from the shared view and its transitionName:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListFragment.kt
val options = ActivityOptions.makeSceneTransitionAnimation(
    requireActivity(),
    photoView,
    getString(R.string.transition_trail_photo),
)
startActivity(TrailDetailActivity.newIntent(requireContext(), trail.id), options.toBundle())

TrailMate’s own TrailDetailFragment is reached through NavHostFragment (Navigation with Fragments) rather than a separate Activity, where the equivalent is FragmentNavigator.Extras, built the same way from a view/transition-name pair and passed as a navigate() argument; the transition-name contract and the changeBounds/changeImageTransform transitions themselves are identical between the activity and fragment cases, only the API that carries the pair across the navigation differs. See Android Developers — Start an Activity Using an Animation (shared elements section) and Compose Animation and Gestures for Compose’s own shared-element APIs, built on a different, animation-spec-driven mechanism rather than transitionName.