Last month I shipped a settings screen that looked perfect on my test device and immediately broke on a smaller phone. The bottom buttons were hidden, and users had no way to reach them. The fix was a single view, but it reminded me why ScrollView still matters in 2026: it is the simplest way to make a long screen feel natural in the classic Android View system. I still reach for it when building forms, long help pages, or small dashboards where the content length is unknown. It turned a one-size layout into something that adapts to real screens.
If you have ever stacked a bunch of views in a LinearLayout and wondered why the screen clips, ScrollView is the missing piece. I will show you the mental model I use, how I set it up in XML and Kotlin, the attributes that actually change behavior, and the mistakes that create janky scrolling. I will also cover when I avoid ScrollView entirely, especially in a world where Compose and RecyclerView are often the better fit. By the end you should be able to choose the right scrolling container, wire it cleanly, and keep your UI fast and predictable. ScrollView is not flashy, but its predictability is exactly why I trust it in production.
Mental Model: ScrollView as a Viewport
ScrollView is a ViewGroup that acts like a window onto a taller piece of content. I picture it as a clipboard with a long sheet of paper: you only see the part inside the frame, and your finger moves the sheet up and down. That mental model explains its key rules.
First, a ScrollView can have only one direct child. If you need multiple views, you wrap them in a container such as LinearLayout, ConstraintLayout, or another ViewGroup. The ScrollView scrolls its single child by changing its scroll position; it does not move every child individually. Second, it supports vertical scrolling only. If you need horizontal motion, you should use HorizontalScrollView or a horizontal RecyclerView for lists. Third, it does not recycle its children. Everything inside the child is inflated, measured, and laid out, which is perfect for a small form but expensive for hundreds of rows.
In touch handling, ScrollView intercepts vertical drag gestures so the content moves as a single unit. If a child needs to handle gestures, like a map or a chart, I call requestDisallowInterceptTouchEvent(true) to let that child take over during interaction. For nested layouts with collapsing toolbars, I reach for NestedScrollView because it cooperates with CoordinatorLayout and AppBarLayout. The important idea is this: ScrollView is a simple viewport for a single tall child, not a general list component.
There is also a measurement detail that saves me a lot of debugging time. ScrollView measures its child with an unspecified height. That means a child with layoutheight=‘matchparent‘ behaves more like wrap_content, because the parent is saying, in effect, I do not know how tall you need to be. When you remember that, the strange cases start to make sense: a child that refuses to fill space, or a button that floats in the middle of the screen when the content is short. The fix is almost always to use fillViewport or to pin the ScrollView between other views with proper constraints.
I also keep the gesture stack in mind. ScrollView wants to own vertical drags. If you put something like a ViewPager2 or a map inside it, the two are going to compete. That is where nested scrolling and intercept rules matter, and where I start considering alternative layouts. But as long as the screen is simple and the content is bounded, ScrollView is the most reliable option in the View system.
Building a Simple ScrollView Layout (XML + Kotlin)
When the screen is mostly static, I often use ScrollView as the root so scrolling logic is obvious and constraints stay simple. The example below is complete and runnable, and it forces the content to exceed the screen height.
- Create an Empty Activity project in Android Studio and choose Kotlin or Java.
- Replace
activity_main.xmlwith the layout below. - Run the app on a smaller device or emulator so the body text extends past the bottom.
XML:
<ScrollView xmlns:android='http://schemas.android.com/apk/res/android'
android:id=‘@+id/scroll‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘matchparent‘
android:fillViewport=‘true‘
android:padding=‘16dp‘
android:clipToPadding=‘false‘
android:scrollbars=‘vertical‘
android:overScrollMode=‘ifContentScrolls‘>
<LinearLayout
android:id=‘@+id/content‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘
android:orientation=‘vertical‘>
<TextView
android:id=‘@+id/title‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘
android:text=‘Settings‘
android:textSize=‘24sp‘
android:textStyle=‘bold‘ />
<TextView
android:id=‘@+id/subtitle‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘
android:layout_marginTop=‘8dp‘
android:text=‘These are real options that keep growing as the app grows.‘
android:textSize=‘14sp‘ />
<View
android:layoutwidth=‘matchparent‘
android:layout_height=‘16dp‘ />
<Switch
android:id=‘@+id/switch_notifications‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘
android:text=‘Notifications‘ />
<Switch
android:id=‘@+id/switch_location‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘
android:text=‘Location access‘ />
<CheckBox
android:id=‘@+id/check_beta‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘
android:text=‘Join beta program‘ />
<TextView
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘
android:layout_marginTop=‘12dp‘
android:text=‘About‘
android:textStyle=‘bold‘ />
<TextView
android:id=‘@+id/body‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘
android:layout_marginTop=‘8dp‘
android:lineSpacingExtra=‘4dp‘
android:text=‘This screen is intentionally long to demonstrate scrolling. Add more text here to exceed the device height and verify that you can still reach the call to action at the bottom.‘ />
<View
android:layoutwidth=‘matchparent‘
android:layout_height=‘24dp‘ />
<Button
android:id=‘@+id/cta‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘
android:text=‘Save changes‘ />
<View
android:layoutwidth=‘matchparent‘
android:layout_height=‘24dp‘ />
Kotlin:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val scroll = findViewById(R.id.scroll)
val cta = findViewById
cta.setOnClickListener {
scroll.post {
scroll.fullScroll(View.FOCUS_DOWN)
}
}
scroll.setOnScrollChangeListener { , , scrollY, , ->
if (scrollY > 0) {
cta.alpha = 0.95f
} else {
cta.alpha = 1.0f
}
}
}
}
The key items to notice are fillViewport, which makes short content stretch to the full height, and the single child LinearLayout that actually contains all the views. I keep clipToPadding=‘false‘ so I can later add system bar padding without losing the overscroll glow at the top or bottom. The Kotlin snippet also demonstrates a subtle truth: programmatic scrolling usually requires post or doOnLayout because the ScrollView does not know its final size until after layout.
The Attributes That Actually Matter
ScrollView has a lot of inherited attributes, but only a handful change behavior in a way that matters for everyday work. These are the ones I keep on a mental cheat sheet.
android:fillViewport=‘true‘forces the child to be at least as tall as the ScrollView. It is the simplest fix for floating content and for bottom buttons that should sit near the bottom when content is short.android:overScrollMode=‘ifContentScrolls‘enables the glow only when scrolling is possible. I useneverfor app screens where I want a flat, no-bounce feel.android:scrollbars=‘vertical‘is a small UI affordance that matters on long screens. I often leave it on because it helps users understand there is more content.android:scrollbarStyle=‘insideOverlay‘keeps the scrollbar from taking layout space, which is useful when a form is tight.android:fadeScrollbars=‘false‘keeps them visible. I sometimes do this on settings screens where people need a persistent scroll cue.android:clipToPadding=‘false‘lets content draw inside the padding area. This is essential if you add top and bottom insets for status bar and navigation bar.android:isScrollContainer=‘true‘is rarely needed, but it can help the system know the view should be scrolled when focus changes.
There are Kotlin properties that mirror the XML attributes. I flip them at runtime when I build dynamic screens.
scrollView.isFillViewport = trueis a safe default.scrollView.isSmoothScrollingEnabled = truecontrols smooth scrolling onsmoothScrollTo.scrollView.overScrollMode = View.OVERSCROLLNEVERis a quick way to remove glow for a more static feel.
If you only remember one attribute, make it fillViewport. It resolves the majority of alignment complaints and reduces the need for hacks with spacer views.
Measurement and Layout: Why Things Clip or Float
Most ScrollView problems look like layout bugs but are really measurement rules you forgot about. The big one is that ScrollView does not pass a concrete height to its child. That is why a child with layoutheight=‘matchparent‘ behaves like wrap_content. The child is saying I want to be as tall as you are, and the parent is saying I do not know my height, so the child wraps instead. That is normal, even though it feels odd at first.
If I want a top area that scrolls but a bottom bar that stays pinned, I do not put the bar inside the ScrollView. I constrain the ScrollView between the top and bottom, then anchor the bar to the bottom of the parent. This avoids the classic bug where the button scrolls out of view when it is supposed to be fixed.
Example using ConstraintLayout:
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android=‘http://schemas.android.com/apk/res/android‘
xmlns:app=‘http://schemas.android.com/apk/res-auto‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘matchparent‘>
<ScrollView
android:id=‘@+id/scroll‘
android:layout_width=‘0dp‘
android:layout_height=‘0dp‘
android:fillViewport=‘true‘
app:layoutconstraintToptoTopOf=‘parent‘
app:layoutconstraintBottomtoTopOf=‘@id/bottom_bar‘
app:layoutconstraintStarttoStartOf=‘parent‘
app:layoutconstraintEndtoEndOf=‘parent‘>
<LinearLayout
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘
android:orientation=‘vertical‘>
<Button
android:id=‘@+id/bottom_bar‘
android:layout_width=‘0dp‘
android:layoutheight=‘wrapcontent‘
android:text=‘Continue‘
app:layoutconstraintBottomtoBottomOf=‘parent‘
app:layoutconstraintStarttoStartOf=‘parent‘
app:layoutconstraintEndtoEndOf=‘parent‘ />
The other measurement pitfall is putting a wrap_content height view with an expensive measure pass inside ScrollView, such as a WebView or a map. Those views can be very costly to measure at an unspecified height because they may ask for huge sizes. In those cases I avoid placing them inside ScrollView, or I wrap them in a fixed height container.
Forms, Focus, and Keyboard Behavior
Most real-world ScrollView screens are forms. Forms introduce two complexities: focus and the on-screen keyboard. By default, ScrollView will scroll the focused view into place, which is good, but it is not always enough because the keyboard changes the available space after the focus event. That is where adjustResize or proper insets handling matters.
In a classic View system, the quickest fix is to set android:windowSoftInputMode=‘adjustResize‘ on the Activity. It tells the window to shrink when the keyboard appears so the ScrollView can re-measure and keep the focused field visible. If you cannot change the manifest, you can do the same in code with window.setSoftInputMode.
Manifest snippet:
<activity
android:name=‘.MainActivity‘
android:windowSoftInputMode=‘adjustResize‘ />
If you are using edge-to-edge layouts or you want precise control, I now prefer window insets. It is more work, but it handles navigation bars, cutouts, and keyboards in one place. This is the insets pattern I rely on:
ViewCompat.setOnApplyWindowInsetsListener(scroll) { v, insets ->
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updatePadding(bottom = max(ime.bottom, bars.bottom))
insets
}
The idea is simple: increase the bottom padding by the largest of the keyboard or system bar inset. With clipToPadding=‘false‘, the content can scroll into that padded area without looking cramped, and the last input field is still reachable. I also add android:focusableInTouchMode=‘true‘ to the ScrollView in screens where I want taps outside a field to clear focus.
When a form has a big input near the bottom, I intentionally call requestFocus and then use scroll.post { scroll.smoothScrollTo(0, target.top) } so the user is not surprised by the keyboard covering the field. That small detail makes form screens feel calm instead of jittery.
Accessibility and Focus Navigation
ScrollView is also a focus container, and that matters for keyboard users and screen readers. I think about accessibility early because the reading order of a long screen is easy to get wrong. TalkBack will traverse the view hierarchy in focus order, so your layout structure matters.
These are the changes I make most often:
- Mark logical sections with
android:accessibilityHeading=‘true‘on title TextViews to create quick navigation points. - If focus lands on the ScrollView instead of the first input, set
android:descendantFocusability=‘afterDescendants‘on the container so children receive focus first. - Connect labels to inputs with
android:labelForso the label is read when the field gains focus. - Avoid wrapping entire sections in a single clickable parent, which can swallow child focus and make the page feel like one giant button.
I also check that the scrollbars are visible enough. They help sighted users understand there is more content, and they complement TalkBack by giving a visual sense of progress. When a screen is very long, I add a floating shortcut like a back-to-top action to improve navigation, but I only do that for extreme cases.
Scrolling to Content Programmatically
Programmatic scrolling is a normal requirement for forms and step flows. The most important rule is to wait until layout is complete. If you call scrollTo in onCreate, the ScrollView has not measured yet, so you will often scroll to the wrong place.
The safe patterns I use:
scroll.post { scroll.smoothScrollTo(0, target.top) }when I want to scroll after layout.scroll.doOnLayout { scroll.scrollTo(0, savedY) }when restoring state.scroll.fullScroll(View.FOCUS_DOWN)when I want to jump to the bottom.
I also keep in mind that target.top is relative to the target parent, not necessarily the ScrollView. If the target is nested in another layout, I use target.getDrawingRect and scroll.offsetDescendantRectToMyCoords to convert coordinates. It is extra code, but it avoids the frustrating half-scroll where the field is still partially hidden.
For UI reactions, setOnScrollChangeListener is usually enough. I use it to fade toolbars, show sticky hints, or trigger lazy content loading. If you need to know when the user stops scrolling, a simple approach is to post a delayed runnable on scroll changes and cancel the previous one.
Saving and Restoring Scroll Position
ScrollView will save and restore its scroll position automatically if it has an id and state saving is enabled. That usually works in an Activity across configuration changes. The issue appears when the view is recreated in a Fragment, inside a pager, or when you manually disable state saving.
When I need full control, I store scrollY and restore after layout. A minimal pattern looks like this:
private const val KEYSCROLLY = "scroll_y"
private var pendingScrollY: Int? = null
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(KEYSCROLLY, scroll.scrollY)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
pendingScrollY = savedInstanceState.getInt(KEYSCROLLY, 0)
scroll.doOnLayout {
pendingScrollY?.let { y ->
scroll.scrollTo(0, y)
pendingScrollY = null
}
}
}
I keep the restore inside doOnLayout because the ScrollView needs a measured height before it can scroll accurately. If I am in a Fragment, I do the same work in onViewStateRestored so the view hierarchy is ready.
Nested Scrolling and CoordinatorLayout
If your screen has a collapsing toolbar, a search bar that pins, or a pull-to-refresh behavior, ScrollView alone is not enough. You want androidx.core.widget.NestedScrollView, which participates in nested scrolling and plays nicely with CoordinatorLayout and AppBarLayout.
A minimal setup looks like this:
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android=‘http://schemas.android.com/apk/res/android‘
xmlns:app=‘http://schemas.android.com/apk/res-auto‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘matchparent‘>
<com.google.android.material.appbar.AppBarLayout
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘>
<androidx.core.widget.NestedScrollView
android:id=‘@+id/scroll‘
android:layoutwidth=‘matchparent‘
android:layoutheight=‘matchparent‘
android:fillViewport=‘true‘
app:layoutbehavior=‘@string/appbarscrollingviewbehavior‘>
<LinearLayout
android:layoutwidth=‘matchparent‘
android:layoutheight=‘wrapcontent‘
android:orientation=‘vertical‘>
NestedScrollView has almost the same API as ScrollView. The difference is that it cooperates with parents and children that also support nested scrolling. If you are mixing CoordinatorLayout, AppBarLayout, or SwipeRefreshLayout, NestedScrollView is the safer default.
Performance and Jank: What Actually Slows You Down
ScrollView is fast when the content is modest and predictable. It becomes slow when you treat it like a list. Because it does not recycle, every child view is measured and drawn every time. That is fine for tens of views, often still fine for low hundreds, but it becomes a problem once you hit heavy layouts or complex view trees.
Here is how I keep ScrollView smooth:
- Keep the layout hierarchy shallow. A LinearLayout inside a ScrollView is fine. Five nested LinearLayouts with weights are not.
- Prefer ConstraintLayout for complex rows to reduce nested parents.
- Avoid
layout_weightinside ScrollView when possible, because it triggers extra measure passes. - Do not put a large RecyclerView inside ScrollView. If you have a list, use RecyclerView as the main container and add a header view.
- Watch expensive children like WebView, MapView, and large ImageView. Give them fixed heights or lazy-load them.
The jank you feel on scroll is often overdraw or layout thrashing. A simple rule helps: if the content is dynamic or unbounded, ScrollView is the wrong tool. Use RecyclerView or Compose LazyColumn and let them recycle views. If the content is static and sized, ScrollView will usually be the fastest path to a stable UI.
Common Pitfalls (and Fixes)
I have seen the same mistakes for years. Most of them are easy to fix once you know the rule.
- Problem: Only the first view shows and the rest are clipped. Fix: ScrollView can only have one direct child. Wrap everything inside a single container.
- Problem: The button floats halfway up the screen when there is little content. Fix: Set
android:fillViewport=‘true‘and use proper constraints. - Problem: The screen does not scroll at all. Fix: Make sure the child height is
wrap_contentand the content actually exceeds the screen height. - Problem: The bottom field is hidden behind the keyboard. Fix: Use
adjustResizeor window insets with padding. - Problem: Nested lists feel jerky. Fix: Replace the ScrollView with RecyclerView, or disable nested scrolling on the inner list when it is truly small.
- Problem: Collapsing toolbar does not react. Fix: Use NestedScrollView and the
appbarscrollingview_behavior.
Whenever I see a broken ScrollView, the cause is almost always one of these. I keep this list in mind when I jump into an older codebase.
When I Skip ScrollView
ScrollView is great, but I do not use it everywhere. I skip it when the content length is unknown or potentially large, or when I need complex interaction with lists and headers.
Here is the quick mental table I use:
Use case
Why
Static settings page
Simple, predictable, no recycling needed
Long list of items
Recycling and paging are built in
Mixed header + list
One scroll container avoids nested scroll conflicts
Compose screen
Compose idioms and state handling
Complex collapsing toolbar
Nested scrolling support
The boundary is not strict, but it is reliable. If I catch myself counting rows or adding placeholder views to make a ScrollView feel like a list, I stop and move to RecyclerView. It saves me time and avoids performance regressions.
Practical Patterns I Reuse
Over time I have collected a handful of patterns that make ScrollView screens feel deliberate instead of accidental.
One pattern is a short settings screen with a fixed bottom action. The ScrollView holds the fields, and the button stays outside. This gives me a clear call to action regardless of content length. When the fields are few, fillViewport keeps the button visually anchored.
Another pattern is a help or legal page. The ScrollView holds the text, with a top anchor and section headings. I add scrollbars=‘vertical‘ and keep line spacing slightly generous. On long text, the scrollbar is a subtle but valuable progress indicator.
For small dashboards, I put cards in a vertical LinearLayout inside ScrollView. Each card is a static summary. If a card needs its own list, I replace the entire screen with RecyclerView and treat the cards as rows. That is the cleanest way to avoid nested scroll conflicts.
For forms, I favor a single column layout with explicit spacing and adjustResize. I avoid complex animations inside ScrollView because they can trigger re-measurement. If a form step expands on selection, I animate height once and then let the ScrollView settle.
When I need to expose advanced options, I use a collapsed section inside ScrollView. I add a toggle and use View.GONE to show or hide the advanced content. That keeps the layout simple and avoids extra containers.
Testing and QA Tips
ScrollView behavior is easy to break and hard to notice on your own device. I run through a quick QA checklist on at least two screen sizes. Small phones reveal the real issues.
In Espresso tests, remember that scrollTo() only works inside ScrollView and NestedScrollView. This is a common source of failing tests if you later swap the container.
Example snippet:
onView(withId(R.id.cta)).perform(scrollTo(), click())
I also add a regression test for the smallest supported screen and run through the form with the keyboard open. If the last field is still visible and the submit button can be reached, the ScrollView is behaving correctly.
Debug Checklist Before Shipping
- Verify that content exceeds the screen on a small device and can be reached.
- Toggle
fillViewportand confirm the short-content case still looks intentional. - Open the keyboard on the last input field and confirm it stays visible.
- Check that the bottom insets do not hide the call to action.
- Make sure there is only one direct child under the ScrollView.
- Confirm that no large lists are nested inside the ScrollView.
Closing Thoughts
ScrollView is not the most modern widget in the Android toolkit, but it is still the fastest way to make a long screen work across device sizes. I use it when the content is mostly static, the number of views is small, and the behavior needs to be predictable. When the screen starts to look like a list or a feed, I switch to RecyclerView or Compose LazyColumn. Knowing that boundary is the real skill.
If you keep the viewport mental model, respect the single-child rule, and handle insets and measurement carefully, ScrollView will stop surprising you. It becomes a reliable, boring component, and in UI engineering, boring is often exactly what we want.


