Visitar URL original
feat(core): iOS 26 scroll edge effect on ScrollView and ListView · NativeScript/NativeScript@20ec874 · GitHub
Skip to content

Commit 20ec874

Browse files
committed
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.
1 parent fcd66cf commit 20ec874

11 files changed

Lines changed: 316 additions & 5 deletions

File tree

‎apps/automated/src/ui/list-view/list-view-tests.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class ListViewTest extends UITest<ListView> {
4949
// << article-create-listview
5050

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

5455
public test_set_items_to_array_loads_all_items(done) {

‎apps/automated/src/ui/scroll-view/scroll-view-tests.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,34 @@ class ScrollLayoutTest extends UITest<ScrollView> {
3535
TKUnit.assertTrue(scrollView !== null, 'ScrollView should be created.');
3636
}
3737

38+
public test_default_iosScrollEdgeEffect() {
39+
const scroll = new ScrollView();
40+
TKUnit.assertEqual(scroll.iosScrollEdgeEffect, 'automatic', 'Default iosScrollEdgeEffect');
41+
}
42+
43+
public test_iosScrollEdgeEffect_maps_to_native_edge_effects() {
44+
const nativeEffects = __APPLE__ && parseFloat(UIDevice.currentDevice.systemVersion) >= 26;
45+
this.testView.iosScrollEdgeEffect = 'none';
46+
if (nativeEffects) {
47+
const native = this.testView.ios as UIScrollView;
48+
TKUnit.assertTrue(native.topEdgeEffect.hidden, 'none hides the top edge effect');
49+
TKUnit.assertTrue(native.bottomEdgeEffect.hidden, 'none hides the bottom edge effect');
50+
}
51+
this.testView.iosScrollEdgeEffect = 'soft';
52+
if (nativeEffects) {
53+
const native = this.testView.ios as UIScrollView;
54+
TKUnit.assertFalse(native.topEdgeEffect.hidden, 'soft shows the top edge effect');
55+
TKUnit.assertTrue(native.topEdgeEffect.style.isEqual(UIScrollEdgeEffectStyle.softStyle), 'soft maps to the soft style');
56+
}
57+
}
58+
59+
public test_addScrollEdgeContainer_accepts_unloaded_views() {
60+
const scroll = new ScrollView();
61+
const bar = new Button();
62+
scroll.addScrollEdgeContainer(bar, 'top');
63+
scroll.removeScrollEdgeContainer(bar);
64+
}
65+
3866
public test_default_TNS_values() {
3967
const scroll = new ScrollView();
4068
TKUnit.assertEqual(scroll.orientation, 'vertical', 'Default this.testView.orientation');

‎packages/core/core-types/index.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,22 @@ export namespace CoreTypes {
307307
export const ltr = 'ltr';
308308
export const rtl = 'rtl';
309309
}
310+
311+
export type ScrollEdgeEffectType = 'automatic' | 'soft' | 'hard' | 'none';
312+
export namespace ScrollEdgeEffect {
313+
export const automatic = 'automatic';
314+
export const soft = 'soft';
315+
export const hard = 'hard';
316+
export const none = 'none';
317+
}
318+
319+
export type ScrollEdgeType = 'top' | 'bottom' | 'left' | 'right';
320+
export namespace ScrollEdge {
321+
export const top = 'top';
322+
export const bottom = 'bottom';
323+
export const left = 'left';
324+
export const right = 'right';
325+
}
310326
}
311327

312328
/**
@@ -388,6 +404,8 @@ export const Enums = {
388404
NavigationBarVisibility: CoreTypes.NavigationBarVisibility,
389405
Orientation: CoreTypes.Orientation,
390406
ReturnKeyType: CoreTypes.ReturnKeyType,
407+
ScrollEdge: CoreTypes.ScrollEdge,
408+
ScrollEdgeEffect: CoreTypes.ScrollEdgeEffect,
391409
StatusBarStyle: CoreTypes.StatusBarStyle,
392410
Stretch: CoreTypes.ImageStretch,
393411
SystemAppearance: CoreTypes.SystemAppearance,

‎packages/core/ui/core/view/view-helper/index.d.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Position, View } from '..';
2+
import { CoreTypes } from '../../../../core-types';
23

34
export class ViewHelper {
45
/**
@@ -63,6 +64,11 @@ export namespace IOSHelper {
6364
* @param view The view form which to start the search.
6465
*/
6566
export function getParentWithViewController(view: View): View;
67+
/**
68+
* iOS 26+: styles the scroll edge effect on every edge of a native scroll
69+
* view, or hides them all for `none`. No-op before iOS 26.
70+
*/
71+
export function setScrollEdgeEffect(scrollView: any /* UIScrollView */, effect: CoreTypes.ScrollEdgeEffectType): void;
6672
export function invalidateStatusBarAppearance(controller?: any /* UIViewController */, reason?: string): void;
6773
export function updateAutoAdjustScrollInsets(controller: any /* UIViewController */, owner: View): void;
6874
export function updateConstraints(controller: any /* UIViewController */, owner: View): void;
@@ -81,3 +87,18 @@ export namespace IOSHelper {
8187
public static initWithOwnerAndCallback(owner: WeakRef<View>, whenClosedCallback: Function): UIPopoverPresentationControllerDelegateImp;
8288
}
8389
}
90+
91+
/**
92+
* iOS 26+: bars a scroll view's content passes beneath, registered with UIKit
93+
* so the scroll view's edge effect covers them and follows them as they move.
94+
*/
95+
export class ScrollEdgeContainers {
96+
constructor(owner: { nativeViewProtected: any /* UIScrollView */ });
97+
/** Registers `view` at `edge`; completes once both native views exist. */
98+
add(view: View, edge: CoreTypes.ScrollEdgeType): void;
99+
remove(view: View): void;
100+
/** Registers every entry whose native views exist. */
101+
attach(): void;
102+
/** Unregisters every entry; they register again on the next attach. */
103+
detach(): void;
104+
}

‎packages/core/ui/core/view/view-helper/index.ios.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { ViewHelper } from './view-helper-common';
77
import { SDK_VERSION } from '../../../../utils/constants';
88
import { layout, Trace } from './view-helper-shared';
99
import { ios as iosUtils, getWindow } from '../../../../utils';
10+
import { CoreTypes } from '../../../../core-types';
11+
import type { EventData } from '../../../../data/observable';
1012

1113
export * from './view-helper-common';
1214
export const AndroidHelper = 0;
@@ -208,13 +210,116 @@ class UIPopoverPresentationControllerDelegateImp extends NSObject implements UIP
208210
}
209211
}
210212

213+
// UIRectEdge values; the enum lives in the UIUtilities typings.
214+
const RECT_EDGES: Record<CoreTypes.ScrollEdgeType, number> = { top: 1, left: 2, bottom: 4, right: 8 };
215+
216+
interface ScrollEdgeContainerEntry {
217+
edge: CoreTypes.ScrollEdgeType;
218+
interaction: UIScrollEdgeElementContainerInteraction | null;
219+
}
220+
221+
/**
222+
* Bars a scroll view's content passes beneath, registered with UIKit so its
223+
* scroll edge effect covers them (iOS 26+). A bar may load after the scroll
224+
* view, be recreated, or live in another window (a keyboard accessory), so
225+
* entries are kept per NativeScript view and registered whenever both native
226+
* views exist.
227+
*/
228+
export class ScrollEdgeContainers {
229+
private readonly entries = new Map<View, ScrollEdgeContainerEntry>();
230+
private readonly onContainerLoaded = () => this.attach();
231+
private readonly onContainerUnloaded = (args: EventData) => {
232+
const view = args.object as View;
233+
const entry = this.entries.get(view);
234+
if (entry) {
235+
this.release(view, entry);
236+
}
237+
};
238+
239+
constructor(private readonly owner: { nativeViewProtected: UIScrollView }) {}
240+
241+
add(view: View, edge: CoreTypes.ScrollEdgeType): void {
242+
this.remove(view);
243+
this.entries.set(view, { edge, interaction: null });
244+
view.on('loaded', this.onContainerLoaded);
245+
view.on('unloaded', this.onContainerUnloaded);
246+
this.attach();
247+
}
248+
249+
remove(view: View): void {
250+
const entry = this.entries.get(view);
251+
if (!entry) {
252+
return;
253+
}
254+
view.off('loaded', this.onContainerLoaded);
255+
view.off('unloaded', this.onContainerUnloaded);
256+
this.release(view, entry);
257+
this.entries.delete(view);
258+
}
259+
260+
/** Registers every entry whose native views exist. */
261+
attach(): void {
262+
const scrollView = this.owner.nativeViewProtected;
263+
if (!scrollView || SDK_VERSION < 26) {
264+
return;
265+
}
266+
this.entries.forEach((entry, view) => {
267+
const container = view.nativeViewProtected as UIView;
268+
if (entry.interaction || !container) {
269+
return;
270+
}
271+
const interaction = UIScrollEdgeElementContainerInteraction.new();
272+
interaction.scrollView = scrollView;
273+
interaction.edge = RECT_EDGES[entry.edge];
274+
container.addInteraction(interaction);
275+
entry.interaction = interaction;
276+
});
277+
}
278+
279+
/** Unregisters every entry; they register again on the next attach. */
280+
detach(): void {
281+
this.entries.forEach((entry, view) => this.release(view, entry));
282+
}
283+
284+
private release(view: View, entry: ScrollEdgeContainerEntry): void {
285+
if (!entry.interaction) {
286+
return;
287+
}
288+
(view.nativeViewProtected as UIView)?.removeInteraction(entry.interaction);
289+
entry.interaction = null;
290+
}
291+
}
292+
211293
export class IOSHelper {
212294
static traitCollectionColorAppearanceChangedEvent = 'traitCollectionColorAppearanceChanged';
213295
static traitCollectionLayoutDirectionChangedEvent = 'traitCollectionLayoutDirectionChanged';
214296
static UILayoutViewController = UILayoutViewController;
215297
static UIAdaptivePresentationControllerDelegateImp = UIAdaptivePresentationControllerDelegateImp;
216298
static UIPopoverPresentationControllerDelegateImp = UIPopoverPresentationControllerDelegateImp;
217299

300+
/**
301+
* Styles the scroll edge effect on every edge of a scroll view, or hides
302+
* them all for `none`. No-op before iOS 26.
303+
*/
304+
static setScrollEdgeEffect(scrollView: UIScrollView, effect: CoreTypes.ScrollEdgeEffectType): void {
305+
if (!scrollView || SDK_VERSION < 26) {
306+
return;
307+
}
308+
const hidden = effect === CoreTypes.ScrollEdgeEffect.none;
309+
let style = UIScrollEdgeEffectStyle.automaticStyle;
310+
if (effect === CoreTypes.ScrollEdgeEffect.soft) {
311+
style = UIScrollEdgeEffectStyle.softStyle;
312+
} else if (effect === CoreTypes.ScrollEdgeEffect.hard) {
313+
style = UIScrollEdgeEffectStyle.hardStyle;
314+
}
315+
for (const edgeEffect of [scrollView.topEdgeEffect, scrollView.bottomEdgeEffect, scrollView.leftEdgeEffect, scrollView.rightEdgeEffect]) {
316+
edgeEffect.hidden = hidden;
317+
if (!hidden) {
318+
edgeEffect.style = style;
319+
}
320+
}
321+
}
322+
218323
static getParentWithViewController(view: View): View {
219324
while (view && !view.viewController) {
220325
view = view.parent as View;

‎packages/core/ui/list-view/index.d.ts‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@ export class ListView extends View {
4040
/**
4141
* 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.
4242
*/
43-
android: any /* android.widget.ListView */;
43+
android: any; /* android.widget.ListView */
4444

4545
/**
4646
* 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.
4747
*/
48-
ios: any /* UITableView */;
48+
ios: any; /* UITableView */
4949

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

176+
/**
177+
* iOS 26+ only. The scroll edge effect UIKit draws where the list scrolls
178+
* under a bar, applied to every edge of the native table view.
179+
*
180+
* - `automatic` (default): UIKit chooses.
181+
* - `soft`: rows beneath the status bar (and any bar registered with
182+
* `addScrollEdgeContainer`) blur and fade out.
183+
* - `hard`: a flat edge instead of a blur.
184+
* - `none`: no effect.
185+
*
186+
* Ignored before iOS 26 and on Android.
187+
*
188+
* @nsProperty
189+
*/
190+
iosScrollEdgeEffect: CoreTypes.ScrollEdgeEffectType;
191+
176192
/**
177193
* Forces the ListView to reload all its items.
178194
*/
179195
refresh();
180196

197+
/**
198+
* iOS 26+ only. Registers `view` as a bar this list's rows pass beneath:
199+
* the scroll edge effect extends under the bar and follows it as it moves.
200+
* A bar in another window (a keyboard accessory) works too. Either view
201+
* may still be loading; registration completes once both native views
202+
* exist. No-op elsewhere.
203+
* @param view The bar.
204+
* @param edge The edge of the list the bar sits on.
205+
*/
206+
addScrollEdgeContainer(view: View, edge: CoreTypes.ScrollEdgeType): void;
207+
208+
/**
209+
* Unregisters a bar passed to `addScrollEdgeContainer`.
210+
*/
211+
removeScrollEdgeContainer(view: View): void;
212+
181213
/**
182214
* Scrolls the specified item with index into view.
183215
* [iOS](https://developer.apple.com/library/prerelease/ios/documentation/UIKit/Reference/UITableView_Class/#//apple_ref/occ/instm/UITableView/scrollToRowAtIndexPath:atScrollPosition:animated:)

‎packages/core/ui/list-view/index.ios.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { ItemEventData, SearchEventData, ItemsSource } from '.';
2-
import { ListViewBase, separatorColorProperty, itemTemplatesProperty, iosEstimatedRowHeightProperty, stickyHeaderProperty, stickyHeaderTemplateProperty, stickyHeaderHeightProperty, sectionedProperty, showSearchProperty, searchAutoHideProperty, iosSearchInsetBehaviorProperty, ListViewSearchInsetBehavior } from './list-view-common';
2+
import { ListViewBase, separatorColorProperty, itemTemplatesProperty, iosEstimatedRowHeightProperty, stickyHeaderProperty, stickyHeaderTemplateProperty, stickyHeaderHeightProperty, sectionedProperty, showSearchProperty, searchAutoHideProperty, iosSearchInsetBehaviorProperty, iosScrollEdgeEffectProperty, ListViewSearchInsetBehavior } from './list-view-common';
33
import { CoreTypes } from '../../core-types';
44
import { View, type KeyedTemplate, type Template } from '../core/view';
5+
import { IOSHelper, ScrollEdgeContainers } from '../core/view/view-helper';
56
import { Length } from '../styling/length-shared';
67
import { Observable, EventData } from '../../data/observable';
78
import { Color } from '../../color';
@@ -450,6 +451,7 @@ export class ListView extends ListViewBase {
450451
private _searchDelegate: UISearchResultsUpdatingImpl;
451452
_isSearchActive: boolean = false;
452453
widthMeasureSpec = 0;
454+
private _scrollEdgeContainers: ScrollEdgeContainers;
453455

454456
constructor() {
455457
super();
@@ -481,15 +483,28 @@ export class ListView extends ListViewBase {
481483
}
482484

483485
this._setNativeClipToBounds();
486+
this._scrollEdgeContainers?.attach();
484487
}
485488

486489
disposeNativeView() {
490+
this._scrollEdgeContainers?.detach();
487491
this._cleanupSearchController();
488492
this._delegate = null;
489493
this._dataSource = null;
490494
super.disposeNativeView();
491495
}
492496

497+
public addScrollEdgeContainer(view: View, edge: CoreTypes.ScrollEdgeType): void {
498+
if (!this._scrollEdgeContainers) {
499+
this._scrollEdgeContainers = new ScrollEdgeContainers(this);
500+
}
501+
this._scrollEdgeContainers.add(view, edge);
502+
}
503+
504+
public removeScrollEdgeContainer(view: View): void {
505+
this._scrollEdgeContainers?.remove(view);
506+
}
507+
493508
private _setupSearchController() {
494509
if (!this.showSearch || this._searchController) {
495510
return; // Already setup or not needed
@@ -1191,4 +1206,11 @@ export class ListView extends ListViewBase {
11911206
}
11921207
// If search is not enabled yet, the property will be used when _setupSearchController is called
11931208
}
1209+
1210+
// Keep this after the `string | Template` setNative: TypeScript folds the
1211+
// computed symbol members into one index signature, and a string-union
1212+
// member declared ahead of that one fails the index-type check (TS2411).
1213+
[iosScrollEdgeEffectProperty.setNative](value: CoreTypes.ScrollEdgeEffectType) {
1214+
IOSHelper.setScrollEdgeEffect(this.nativeViewProtected, value);
1215+
}
11941216
}

‎packages/core/ui/list-view/list-view-common.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ export abstract class ListViewBase extends ContainerView implements ListViewDefi
6060
public showSearch: boolean;
6161
public searchAutoHide: boolean;
6262
public iosSearchInsetBehavior: ListViewSearchInsetBehavior;
63+
public iosScrollEdgeEffect: CoreTypes.ScrollEdgeEffectType;
64+
65+
public addScrollEdgeContainer(view: View, edge: CoreTypes.ScrollEdgeType): void {
66+
// iOS 26+ only; see the iOS implementation.
67+
}
68+
69+
public removeScrollEdgeContainer(view: View): void {
70+
// iOS 26+ only; see the iOS implementation.
71+
}
6372

6473
get separatorColor(): Color {
6574
return this.style.separatorColor;
@@ -393,3 +402,9 @@ export const iosSearchInsetBehaviorProperty = new Property<ListViewBase, ListVie
393402
name: 'iosSearchInsetBehavior',
394403
});
395404
iosSearchInsetBehaviorProperty.register(ListViewBase);
405+
406+
export const iosScrollEdgeEffectProperty = new Property<ListViewBase, CoreTypes.ScrollEdgeEffectType>({
407+
name: 'iosScrollEdgeEffect',
408+
defaultValue: 'automatic',
409+
});
410+
iosScrollEdgeEffectProperty.register(ListViewBase);

0 commit comments

Comments
 (0)