Scroll view

<scroll-view> wraps its content in an internal container the way React Native's own ScrollView.js does, because native scroll views have no equivalent to contentContainerStyle of their own. contentContainerStyle styles that inner container - padding and gaps on the scrollable content go there, not on [style], which styles the scroll view's own frame.

Import
import { ScrollView, RefreshControl } from '@ng-native/components';
Template
<scroll-view><refresh-control> once they are in the component's imports
<scroll-view #list [contentContainerStyle]="{ padding: 16, gap: 12 }">
  @for (item of items(); track item.id) {
  <text>{{ item.label }}</text>
  }
</scroll-view>

contentContainerClass gives the same container classes instead, as NativeWind's contentContainerClassName does. The container is matched as if it were written in your own template, so Tailwind utilities and your component's own styles both reach it. contentContainerStyle wins over a class, as an inline style does. Without a class the container is the scroll view's own again, and your styles no longer reach it.

<scroll-view contentContainerClass="gap-3 p-4">
  @for (item of items(); track item.id) {
  <text>{{ item.label }}</text>
  }
</scroll-view>

Horizontal scrolling

horizontal changes the scroll axis, and it changes two things to do it: the scroll view's own flex direction and the content container's. Setting only one leaves the content stretched to the scroll view's width with nothing to scroll to, which looks exactly like a horizontal scroll view that simply does not have enough in it to scroll - horizontal handles both, so this only matters if something is overriding the content container's own layout.

On Android a horizontal scroll view is a different native view from a vertical one, as it is in React Native, and so is the view that holds its content. Changing horizontal after the scroll view is on screen makes it again, with everything in it, so its scroll position starts over. iOS has one scroll view for both axes and changes it in place.

Keyboard taps

keyboardShouldPersistTaps controls what a tap elsewhere in the scroll view does while a <text-input> has focus: 'never' (the default) dismisses the keyboard immediately and nothing else happens with the tap, 'handled' lets whatever was tapped handle it first and only dismisses if nothing did, and 'always' never dismisses at all.

A tap on another <text-input> never dismisses, under any of the three: the field takes the focus and the keyboard stays up, so moving from one field of a form to the next takes one tap, as in React Native.

Keyboard insets on iOS

automaticallyAdjustKeyboardInsets gives the scroll view a bottom inset as tall as the part of it the keyboard covers, so UIKit can scroll a focused field above the keyboard. React Native does this in native code, and in React Native 0.88 it gets two things wrong when contentInsetAdjustmentBehavior is anything but never, its default, as on a page under a native header:

  • The content jumps under the bar. Under a navigation bar the scroll view rests scrolled above zero, by the inset UIKit adds for the bar. When the keyboard comes up, React Native scrolls to where the scroll view already is, but clamps that offset with the scroll view's own contentInset rather than the inset UIKit adds, so it lands at zero: focusing a field that is already in view moves the content up by the bar's height, more with a large title, under the bar.
  • A focused field stops short of the keyboard. The inset React Native sets is the keyboard's overlap with the scroll view, which already takes in the home indicator's strip, and UIKit adds the bottom safe area on top of it. On an iPhone with a home indicator a focused field stops about 34 points above the keyboard, or above a bar docked on it, and the same space is left blank under the content while the keyboard is up.

Both come from _keyboardWillChangeFrame: and scrollTo:y:animated: in React Native's RCTScrollViewComponentView.mm, not from anything Angular Native sends: for a field focused while the keyboard is already up, it scrolls only when the field is out of view.

For a form, leave the page's insets to the page instead. contentInsetAdjustmentBehavior="never" stops UIKit adding any, so React Native's keyboard inset is the only one, and a <screen-safe-area-view> keeps the scroll view clear of the bar and the home indicator:

<native-header title="Profile" />
<screen-safe-area-view [edges]="['top', 'bottom']" style="flex: 1">
  <scroll-view
    style="flex: 1"
    contentInsetAdjustmentBehavior="never"
    [automaticallyAdjustKeyboardInsets]="true"
  >
    <!-- fields -->
  </scroll-view>
</screen-safe-area-view>

The content then no longer scrolls under the bar, so a large title stays expanded rather than collapsing into it (see Large titles).

Sticky headers

stickyHeaderIndices pins children of the content to the top while the rest scroll under them, each until the next sticky child pushes it off. React Native does this in JavaScript rather than natively, by translating the child against the scroll offset, and so does this. As in RN, the translation runs on the native animation driver, so a pinned header keeps up with a fast fling; the translate is written as a prop only once scrolling pauses.

Methods

scrollTo({ x, y, animated }), scrollToEnd({ animated }), flashScrollIndicators() and, on iOS, zoomToRect(rect, animated) are available as methods through a template reference.

The scroll event

(scroll), (scrollBeginDrag), (scrollEndDrag), (momentumScrollBegin) and (momentumScrollEnd) deliver a ScrollEvent, which has the offset, sizes and insets under nativeEvent, as a React Native scroll handler gets them. They are element events rather than outputs, so $event takes this type with strictDomEventTypes off, as the generated tsconfig.json and Manual setup set it:

import { Component, signal } from '@angular/core';
import { ScrollView, Text, type ScrollEvent } from '@ng-native/components';

@Component({
  selector: 'app-offset',
  imports: [ScrollView, Text],
  template: `
    <scroll-view (scroll)="track($event)">
      <text>Scrolled {{ offset() }}</text>
    </scroll-view>
  `,
})
export class Offset {
  protected readonly offset = signal(0);

  protected track(event: ScrollEvent): void {
    this.offset.set(event.nativeEvent.contentOffset.y);
  }
}

A workletScroll() worklet is different: it runs on the UI thread and takes the native event itself, so it reads event.contentOffset (see Animation).

A scroll handler and change detection

A (scroll) handler runs change detection for its component on every scroll event, and Angular checks every row an @for in that component's template has rendered, whatever the handler changed. A header that fades as the page scrolls, over a thousand rows in the same template, re-checks all thousand rows every frame. Put the list in a component of its own and the same event checks one input instead: with a thousand rows, a scroll event costs about 0.05ms of JavaScript rather than about 2ms.

<scroll-view (scroll)="fadeHeader($event)">
  <view [style]="{ opacity: headerOpacity() }"><text>Inbox</text></view>
  <app-message-rows [messages]="messages()" />
</scroll-view>
scroll-viewcomponent@ng-native/components
import { ScrollView } from '@ng-native/components';and add ScrollView to the component's imports

A scrolling container. Commits as RCTScrollView , with the children inside a content view.

Inputs

contentContainerStyle
Record<string, unknown>

Styles for the view that holds the children, e.g. padding and gap.

contentContainerClass
string

Classes for the view that holds the children, matched as if the view were written in the template the scroll view is. See ContentContainer . contentContainerStyle wins over it, as an inline style does over a class.

horizontal
unknown

Lay children out in a row and scroll sideways.

contentOffset
Point

Where the content starts, before the user scrolls.

maintainVisibleContentPosition
{ readonly minIndexForVisible: number; readonly autoscrollToTopThreshold?: number; }

Keep what is on screen still when content is added above it.

keyboardShouldPersistTaps
KeyboardShouldPersistTaps = 'never'

What a tap does with the keyboard up. See the class note. Defaults to never .

nestedScrollEnabled
unknown

Android: cooperate with a scrolling ancestor.

stickyHeaderIndices
readonly number[] = []

Children of the content, by position, that stick to the leading edge while it scrolls.

Outputs

contentSizeChange
Size

The content view's size changed; the value is its new width and height.

Methods

scrollTo(options: { x?: number; y?: number; animated?: boolean }): void

Scroll to a position.

flashScrollIndicators(): void

Briefly show the scroll indicators, to hint that there is more.

zoomToRect(rect: Rect, animated = true): void

iOS: zoom so that a rectangle of the content fills the viewport.

Refresh control

<refresh-control> is a pull-to-refresh spinner, used as a direct child of a <scroll-view> or <virtual-list>. refreshing is a two-way model: native shows the spinner the instant the user pulls, before the app has said anything, and the app is expected to set refreshing to true when the (refresh) output fires and back to false once the work is done. If an app never clears it, the spinner keeps spinning indefinitely - there is no timeout.

<scroll-view>
  <refresh-control [(refreshing)]="loading" (refresh)="reload()" />
  @for (item of items(); track item.id) {
  <text>{{ item.label }}</text>
  }
</scroll-view>

iOS also reads tintColor, title and titleColor for a label under the spinner. Android reads colors (the colors the spinner cycles through), progressBackgroundColor, size ('default' or 'large') and enabled (whether pulling does anything at all, default true). progressViewOffset sets how far from the top the spinner sits, on both platforms.

A page can start with refreshing true, to show cached data while it reloads. On iOS, under a <native-stack-outlet> or <native-tabs-outlet>, the spinner then starts once the screen has appeared (SCREEN_SHOWN), so on a pushed page it shows as the push finishes. Started earlier, UIKit leaves a gap the spinner's height under a large title, which React Native's own RefreshControl shows too.

On Android the native swipe layout has to be the scroll view's parent, not its child, or a pull does nothing. It is still written as a child, as on iOS, and moved into place: the committed tree has AndroidSwipeRefreshLayout where the scroll view was, with the scroll view inside it. The layout half of the scroll view's inline style (size, margins, flex, position, transform) moves to the swipe layout, the way React Native's ScrollView.js splits it, and the scroll view keeps the rest. Layout that comes from a class stays on the scroll view.

refresh-controldirective@ng-native/components
import { RefreshControl } from '@ng-native/components';and add RefreshControl to the component's imports

Pull to refresh, as a child of a scroll view. Commits as PullToRefreshView on iOS and AndroidSwipeRefreshLayout on Android.

Inputs

progressViewOffset
unknown

How far from the top the spinner sits.

tintColor
string

iOS: the spinner's colour.

title
string

iOS: a label under the spinner.

titleColor
string

iOS: the label's colour.

colors
readonly string[]

Android: the colours the spinner cycles through.

enabled
unknown

Android: whether pulling does anything. Defaults to true.

progressBackgroundColor
string

Android: the spinner's background.

size
'default' | 'large'

Android: the spinner's size.

Two-way

refreshing
boolean = false

Whether the spinner shows. Only the app changes it: a pull fires (refresh) and leaves this alone, so [(refreshing)] and [refreshing] behave the same.

Angular components as native iOS and Android views, built and shipped with Expo.

An alpha. MIT licensed. Sponsor its development.

An independent project, not affiliated with or endorsed by Google, the Angular team or Expo. Angular is a trademark of Google LLC.