Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
feat(core): iOS 26 scroll edge effect on ScrollView and ListView
Core pins contentInsetAdjustmentBehavior to never and lays content out
inside the safe area itself, so UIKit's automatic scroll edge effects
never appear on NativeScript scroll views on iOS 26: content under the
status bar stays crisp and a custom bar gets no fade.

- iosScrollEdgeEffect ('automatic' | 'soft' | 'hard' | 'none') styles or
  hides the effect on every edge; soft on its own already blurs the
  status-bar band of a scroll view that overflows the safe area.
- addScrollEdgeContainer(view, edge) / removeScrollEdgeContainer(view)
  register a bar the content scrolls beneath, through
  UIScrollEdgeElementContainerInteraction, so the effect extends under
  it and follows it. Registration completes once both native views
  exist, survives the bar being recreated, and works for a bar in
  another window.
- CoreTypes.ScrollEdgeEffect and CoreTypes.ScrollEdge.

No-ops before iOS 26 and on Android.
  • Loading branch information
NathanWalker committed Oct 2, 2026
commit bcaafbf954f8955da4b412387b34526ef6c8a6fb
1 change: 1 addition & 0 deletions apps/automated/src/ui/list-view/list-view-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class ListViewTest extends UITest<ListView> {
// << article-create-listview

TKUnit.assert(Utils.isUndefined(listView.items), 'Default listView.items should be undefined');
TKUnit.assertEqual(listView.iosScrollEdgeEffect, 'automatic', 'Default listView.iosScrollEdgeEffect');
}

public test_set_items_to_array_loads_all_items(done) {
Expand Down
28 changes: 28 additions & 0 deletions apps/automated/src/ui/scroll-view/scroll-view-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,34 @@ class ScrollLayoutTest extends UITest<ScrollView> {
TKUnit.assertTrue(scrollView !== null, 'ScrollView should be created.');
}

public test_default_iosScrollEdgeEffect() {
const scroll = new ScrollView();
TKUnit.assertEqual(scroll.iosScrollEdgeEffect, 'automatic', 'Default iosScrollEdgeEffect');
}

public test_iosScrollEdgeEffect_maps_to_native_edge_effects() {
const nativeEffects = __APPLE__ && parseFloat(UIDevice.currentDevice.systemVersion) >= 26;
this.testView.iosScrollEdgeEffect = 'none';
if (nativeEffects) {
const native = this.testView.ios as UIScrollView;
TKUnit.assertTrue(native.topEdgeEffect.hidden, 'none hides the top edge effect');
TKUnit.assertTrue(native.bottomEdgeEffect.hidden, 'none hides the bottom edge effect');
}
this.testView.iosScrollEdgeEffect = 'soft';
if (nativeEffects) {
const native = this.testView.ios as UIScrollView;
TKUnit.assertFalse(native.topEdgeEffect.hidden, 'soft shows the top edge effect');
TKUnit.assertTrue(native.topEdgeEffect.style.isEqual(UIScrollEdgeEffectStyle.softStyle), 'soft maps to the soft style');
}
}

public test_addScrollEdgeContainer_accepts_unloaded_views() {
const scroll = new ScrollView();
const bar = new Button();
scroll.addScrollEdgeContainer(bar, 'top');
scroll.removeScrollEdgeContainer(bar);
}

public test_default_TNS_values() {
const scroll = new ScrollView();
TKUnit.assertEqual(scroll.orientation, 'vertical', 'Default this.testView.orientation');
Expand Down
18 changes: 18 additions & 0 deletions packages/core/core-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,22 @@ export namespace CoreTypes {
export const ltr = 'ltr';
export const rtl = 'rtl';
}

export type ScrollEdgeEffectType = 'automatic' | 'soft' | 'hard' | 'none';
export namespace ScrollEdgeEffect {
export const automatic = 'automatic';
export const soft = 'soft';
export const hard = 'hard';
export const none = 'none';
}

export type ScrollEdgeType = 'top' | 'bottom' | 'left' | 'right';
export namespace ScrollEdge {
export const top = 'top';
export const bottom = 'bottom';
export const left = 'left';
export const right = 'right';
}
}

/**
Expand Down Expand Up @@ -395,6 +411,8 @@ export const Enums = {
NavigationBarVisibility: CoreTypes.NavigationBarVisibility,
Orientation: CoreTypes.Orientation,
ReturnKeyType: CoreTypes.ReturnKeyType,
ScrollEdge: CoreTypes.ScrollEdge,
ScrollEdgeEffect: CoreTypes.ScrollEdgeEffect,
StatusBarStyle: CoreTypes.StatusBarStyle,
Stretch: CoreTypes.ImageStretch,
SystemAppearance: CoreTypes.SystemAppearance,
Expand Down
21 changes: 21 additions & 0 deletions packages/core/ui/core/view/view-helper/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Position, View } from '..';
import { CoreTypes } from '../../../../core-types';

export class ViewHelper {
/**
Expand Down Expand Up @@ -70,6 +71,11 @@ export namespace IOSHelper {
* @param view The view form which to start the search.
*/
export function getParentWithViewController(view: View): View;
/**
* iOS 26+: styles the scroll edge effect on every edge of a native scroll
* view, or hides them all for `none`. No-op before iOS 26.
*/
export function setScrollEdgeEffect(scrollView: any /* UIScrollView */, effect: CoreTypes.ScrollEdgeEffectType): void;
export function invalidateStatusBarAppearance(controller?: any /* UIViewController */, reason?: string): void;
export function updateAutoAdjustScrollInsets(controller: any /* UIViewController */, owner: View): void;
export function updateConstraints(controller: any /* UIViewController */, owner: View): void;
Expand All @@ -95,3 +101,18 @@ export namespace IOSHelper {
public static initWithOwnerAndCallback(owner: WeakRef<View>, whenClosedCallback: Function): UIPopoverPresentationControllerDelegateImp;
}
}

/**
* iOS 26+: bars a scroll view's content passes beneath, registered with UIKit
* so the scroll view's edge effect covers them and follows them as they move.
*/
export class ScrollEdgeContainers {
constructor(owner: { nativeViewProtected: any /* UIScrollView */ });
/** Registers `view` at `edge`; completes once both native views exist. */
add(view: View, edge: CoreTypes.ScrollEdgeType): void;
remove(view: View): void;
/** Registers every entry whose native views exist. */
attach(): void;
/** Unregisters every entry; they register again on the next attach. */
detach(): void;
}
105 changes: 105 additions & 0 deletions packages/core/ui/core/view/view-helper/index.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { ViewHelper } from './view-helper-common';
import { SDK_VERSION } from '../../../../utils/constants';
import { layout, Trace } from './view-helper-shared';
import { ios as iosUtils, getWindow } from '../../../../utils';
import { CoreTypes } from '../../../../core-types';
import type { EventData } from '../../../../data/observable';

export * from './view-helper-common';
export const AndroidHelper = 0;
Expand Down Expand Up @@ -208,13 +210,116 @@ class UIPopoverPresentationControllerDelegateImp extends NSObject implements UIP
}
}

// UIRectEdge values; the enum lives in the UIUtilities typings.
const RECT_EDGES: Record<CoreTypes.ScrollEdgeType, number> = { top: 1, left: 2, bottom: 4, right: 8 };

interface ScrollEdgeContainerEntry {
edge: CoreTypes.ScrollEdgeType;
interaction: UIScrollEdgeElementContainerInteraction | null;
}

/**
* Bars a scroll view's content passes beneath, registered with UIKit so its
* scroll edge effect covers them (iOS 26+). A bar may load after the scroll
* view, be recreated, or live in another window (a keyboard accessory), so
* entries are kept per NativeScript view and registered whenever both native
* views exist.
*/
export class ScrollEdgeContainers {
private readonly entries = new Map<View, ScrollEdgeContainerEntry>();
private readonly onContainerLoaded = () => this.attach();
private readonly onContainerUnloaded = (args: EventData) => {
const view = args.object as View;
const entry = this.entries.get(view);
if (entry) {
this.release(view, entry);
}
};

constructor(private readonly owner: { nativeViewProtected: UIScrollView }) {}

add(view: View, edge: CoreTypes.ScrollEdgeType): void {
this.remove(view);
this.entries.set(view, { edge, interaction: null });
view.on('loaded', this.onContainerLoaded);
view.on('unloaded', this.onContainerUnloaded);
this.attach();
}

remove(view: View): void {
const entry = this.entries.get(view);
if (!entry) {
return;
}
view.off('loaded', this.onContainerLoaded);
view.off('unloaded', this.onContainerUnloaded);
this.release(view, entry);
this.entries.delete(view);
}

/** Registers every entry whose native views exist. */
attach(): void {
const scrollView = this.owner.nativeViewProtected;
if (!scrollView || SDK_VERSION < 26) {
return;
}
this.entries.forEach((entry, view) => {
const container = view.nativeViewProtected as UIView;
if (entry.interaction || !container) {
return;
}
const interaction = UIScrollEdgeElementContainerInteraction.new();
interaction.scrollView = scrollView;
interaction.edge = RECT_EDGES[entry.edge];
container.addInteraction(interaction);
entry.interaction = interaction;
});
}

/** Unregisters every entry; they register again on the next attach. */
detach(): void {
this.entries.forEach((entry, view) => this.release(view, entry));
}

private release(view: View, entry: ScrollEdgeContainerEntry): void {
if (!entry.interaction) {
return;
}
(view.nativeViewProtected as UIView)?.removeInteraction(entry.interaction);
entry.interaction = null;
}
}

export class IOSHelper {
static traitCollectionColorAppearanceChangedEvent = 'traitCollectionColorAppearanceChanged';
static traitCollectionLayoutDirectionChangedEvent = 'traitCollectionLayoutDirectionChanged';
static UILayoutViewController = UILayoutViewController;
static UIAdaptivePresentationControllerDelegateImp = UIAdaptivePresentationControllerDelegateImp;
static UIPopoverPresentationControllerDelegateImp = UIPopoverPresentationControllerDelegateImp;

/**
* Styles the scroll edge effect on every edge of a scroll view, or hides
* them all for `none`. No-op before iOS 26.
*/
static setScrollEdgeEffect(scrollView: UIScrollView, effect: CoreTypes.ScrollEdgeEffectType): void {
if (!scrollView || SDK_VERSION < 26) {
return;
}
const hidden = effect === CoreTypes.ScrollEdgeEffect.none;
let style = UIScrollEdgeEffectStyle.automaticStyle;
if (effect === CoreTypes.ScrollEdgeEffect.soft) {
style = UIScrollEdgeEffectStyle.softStyle;
} else if (effect === CoreTypes.ScrollEdgeEffect.hard) {
style = UIScrollEdgeEffectStyle.hardStyle;
}
for (const edgeEffect of [scrollView.topEdgeEffect, scrollView.bottomEdgeEffect, scrollView.leftEdgeEffect, scrollView.rightEdgeEffect]) {
edgeEffect.hidden = hidden;
if (!hidden) {
edgeEffect.style = style;
}
}
}

static getParentWithViewController(view: View): View {
while (view && !view.viewController) {
view = view.parent as View;
Expand Down
36 changes: 34 additions & 2 deletions packages/core/ui/list-view/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ export class ListView extends View {
/**
* Gets the native [android widget](http://developer.android.com/reference/android/widget/ListView.html) that represents the user interface for this component. Valid only when running on Android OS.
*/
android: any /* android.widget.ListView */;
android: any; /* android.widget.ListView */

/**
* Gets the native [iOS view](https://developer.apple.com/library/ios/documentation/UIKit/Reference/UITableView_Class/) that represents the user interface for this component. Valid only when running on iOS.
*/
ios: any /* UITableView */;
ios: any; /* UITableView */

/**
* Gets or set the items collection of the ListView.
Expand Down Expand Up @@ -173,11 +173,43 @@ export class ListView extends View {
*/
iosSearchInsetBehavior: 'automatic' | 'scrollableAxes' | 'never' | 'always';

/**
* iOS 26+ only. The scroll edge effect UIKit draws where the list scrolls
* under a bar, applied to every edge of the native table view.
*
* - `automatic` (default): UIKit chooses.
* - `soft`: rows beneath the status bar (and any bar registered with
* `addScrollEdgeContainer`) blur and fade out.
* - `hard`: a flat edge instead of a blur.
* - `none`: no effect.
*
* Ignored before iOS 26 and on Android.
*
* @nsProperty
*/
iosScrollEdgeEffect: CoreTypes.ScrollEdgeEffectType;

/**
* Forces the ListView to reload all its items.
*/
refresh();

/**
* iOS 26+ only. Registers `view` as a bar this list's rows pass beneath:
* the scroll edge effect extends under the bar and follows it as it moves.
* A bar in another window (a keyboard accessory) works too. Either view
* may still be loading; registration completes once both native views
* exist. No-op elsewhere.
* @param view The bar.
* @param edge The edge of the list the bar sits on.
*/
addScrollEdgeContainer(view: View, edge: CoreTypes.ScrollEdgeType): void;

/**
* Unregisters a bar passed to `addScrollEdgeContainer`.
*/
removeScrollEdgeContainer(view: View): void;

/**
* Scrolls the specified item with index into view.
* [iOS](https://developer.apple.com/library/prerelease/ios/documentation/UIKit/Reference/UITableView_Class/#//apple_ref/occ/instm/UITableView/scrollToRowAtIndexPath:atScrollPosition:animated:)
Expand Down
24 changes: 23 additions & 1 deletion packages/core/ui/list-view/index.ios.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ItemEventData, SearchEventData, ItemsSource } from '.';
import { ListViewBase, separatorColorProperty, itemTemplatesProperty, iosEstimatedRowHeightProperty, stickyHeaderProperty, stickyHeaderTemplateProperty, stickyHeaderHeightProperty, sectionedProperty, showSearchProperty, searchAutoHideProperty, iosSearchInsetBehaviorProperty, ListViewSearchInsetBehavior } from './list-view-common';
import { ListViewBase, separatorColorProperty, itemTemplatesProperty, iosEstimatedRowHeightProperty, stickyHeaderProperty, stickyHeaderTemplateProperty, stickyHeaderHeightProperty, sectionedProperty, showSearchProperty, searchAutoHideProperty, iosSearchInsetBehaviorProperty, iosScrollEdgeEffectProperty, ListViewSearchInsetBehavior } from './list-view-common';
import { CoreTypes } from '../../core-types';
import { View, type KeyedTemplate, type Template } from '../core/view';
import { IOSHelper, ScrollEdgeContainers } from '../core/view/view-helper';
import { Length } from '../styling/length-shared';
import { Observable, EventData } from '../../data/observable';
import { Color } from '../../color';
Expand Down Expand Up @@ -450,6 +451,7 @@ export class ListView extends ListViewBase {
private _searchDelegate: UISearchResultsUpdatingImpl;
_isSearchActive: boolean = false;
widthMeasureSpec = 0;
private _scrollEdgeContainers: ScrollEdgeContainers;

constructor() {
super();
Expand Down Expand Up @@ -481,15 +483,28 @@ export class ListView extends ListViewBase {
}

this._setNativeClipToBounds();
this._scrollEdgeContainers?.attach();
}

disposeNativeView() {
this._scrollEdgeContainers?.detach();
this._cleanupSearchController();
this._delegate = null;
this._dataSource = null;
super.disposeNativeView();
}

public addScrollEdgeContainer(view: View, edge: CoreTypes.ScrollEdgeType): void {
if (!this._scrollEdgeContainers) {
this._scrollEdgeContainers = new ScrollEdgeContainers(this);
}
this._scrollEdgeContainers.add(view, edge);
}

public removeScrollEdgeContainer(view: View): void {
this._scrollEdgeContainers?.remove(view);
}

private _setupSearchController() {
if (!this.showSearch || this._searchController) {
return; // Already setup or not needed
Expand Down Expand Up @@ -1191,4 +1206,11 @@ export class ListView extends ListViewBase {
}
// If search is not enabled yet, the property will be used when _setupSearchController is called
}

// Keep this after the `string | Template` setNative: TypeScript folds the
// computed symbol members into one index signature, and a string-union
// member declared ahead of that one fails the index-type check (TS2411).
[iosScrollEdgeEffectProperty.setNative](value: CoreTypes.ScrollEdgeEffectType) {
IOSHelper.setScrollEdgeEffect(this.nativeViewProtected, value);
}
}
15 changes: 15 additions & 0 deletions packages/core/ui/list-view/list-view-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ export abstract class ListViewBase extends ContainerView implements ListViewDefi
public showSearch: boolean;
public searchAutoHide: boolean;
public iosSearchInsetBehavior: ListViewSearchInsetBehavior;
public iosScrollEdgeEffect: CoreTypes.ScrollEdgeEffectType;

public addScrollEdgeContainer(view: View, edge: CoreTypes.ScrollEdgeType): void {
// iOS 26+ only; see the iOS implementation.
}

public removeScrollEdgeContainer(view: View): void {
// iOS 26+ only; see the iOS implementation.
}

get separatorColor(): Color {
return this.style.separatorColor;
Expand Down Expand Up @@ -393,3 +402,9 @@ export const iosSearchInsetBehaviorProperty = new Property<ListViewBase, ListVie
name: 'iosSearchInsetBehavior',
});
iosSearchInsetBehaviorProperty.register(ListViewBase);

export const iosScrollEdgeEffectProperty = new Property<ListViewBase, CoreTypes.ScrollEdgeEffectType>({
name: 'iosScrollEdgeEffect',
defaultValue: 'automatic',
});
iosScrollEdgeEffectProperty.register(ListViewBase);
Loading
Loading