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 { ScrollView, RefreshControl } from '@ng-native/components';
<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
contentInsetrather 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/componentsimport { 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
contentContainerStyleRecord<string, unknown>Styles for the view that holds the children, e.g. padding and gap.
contentContainerClassstring 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.
horizontalunknownLay children out in a row and scroll sideways.
contentOffsetPointWhere 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.
keyboardShouldPersistTapsKeyboardShouldPersistTaps = 'never' What a tap does with the keyboard up. See the class note. Defaults to never .
nestedScrollEnabledunknownAndroid: cooperate with a scrolling ancestor.
stickyHeaderIndicesreadonly number[] = []Children of the content, by position, that stick to the leading edge while it scrolls.
Outputs
contentSizeChangeSizeThe content view's size changed; the value is its new width and height.
Methods
scrollTo(options: { x?: number; y?: number; animated?: boolean }): voidScroll to a position.
flashScrollIndicators(): voidBriefly show the scroll indicators, to hint that there is more.
zoomToRect(rect: Rect, animated = true): voidiOS: 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/componentsimport { 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
progressViewOffsetunknownHow far from the top the spinner sits.
tintColorstringiOS: the spinner's colour.
titlestringiOS: a label under the spinner.
titleColorstringiOS: the label's colour.
colorsreadonly string[]Android: the colours the spinner cycles through.
enabledunknownAndroid: whether pulling does anything. Defaults to true.
progressBackgroundColorstringAndroid: the spinner's background.
size'default' | 'large'Android: the spinner's size.
Two-way
refreshingboolean = 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.