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 |
|---|---|---|
|
A redraw only ( |
The user drags a finger and |
|
A new measure + layout pass for this view (and, if its size changes, its parent), then a redraw. |
A new |
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 |
|---|---|
|
Animating one or a few of a |
|
Animating any property — a |
|
Animating a plain value over time with no target object at all; the caller reads the current value from an
|
|
Choreographing several |
// 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.
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.