Skip to content

Commit 75cbfc5

Browse files
authored
fix(core): nested tab frames stay loaded and never outrank the selected tab (#11446)
[skip ci]
1 parent 6e8d20e commit 75cbfc5

14 files changed

Lines changed: 498 additions & 6 deletions

‎apps/toolbox/src/main-page.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
<Button text="list-page-sticky" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
2323
<Button text="list-page-sticky-templates" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
2424
<Button text="multiple-scenes" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
25+
<Button text="nested-tab-frame" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
26+
<Button text="nested-tab-frame-4tabs" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
2527
<Button text="root-layout" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
2628
<Button text="scroll-view" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
2729
<Button text="sliders" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { EventData, Page } from '@nativescript/core';
2+
import { setupPage } from './nested-tab-frame-shared';
3+
4+
export function navigatingTo(args: EventData) {
5+
setupPage(<Page>args.object, { tabCount: 4 });
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
2+
<Page.actionBar>
3+
<ActionBar title="Nested Tab Frame (4 tabs)" icon="" class="action-bar">
4+
</ActionBar>
5+
</Page.actionBar>
6+
</Page>
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { Button, Frame, Label, Page, ScrollView, StackLayout, TabView, TabViewItem, Trace } from '@nativescript/core';
2+
3+
/**
4+
* Playground for a Frame hosted as a TabViewItem view (issue #11444): every tab frame mounts
5+
* its first page in `loaded`, and the inner pages expose the navigation and frame-stack
6+
* probes used to reproduce the reported stalls.
7+
*/
8+
export interface NestedTabFrameOptions {
9+
tabCount: number;
10+
androidOffscreenTabLimit?: number;
11+
}
12+
13+
const TITLES = ['Home', 'Demos', 'Third', 'Fourth'];
14+
15+
export const registry = { tabView: null as TabView, frames: [] as Frame[] };
16+
17+
export function log(msg: string) {
18+
console.log(`[NTF] ${msg}`);
19+
}
20+
21+
export function describe(frame: Frame): string {
22+
const f = frame as any;
23+
return `isLoaded=${frame.isLoaded} current=${frame.currentPage ? frame.currentPage.id : null} backStack=${frame.backStack.length} executing=${!!f._executingContext} queue=${f._navigationQueue.length} inStack=${f._isInFrameStack}`;
24+
}
25+
26+
export function makePage(frame: Frame, depth: number): Page {
27+
const page = new Page();
28+
page.id = `${frame.id}-page${depth}`;
29+
page.actionBarHidden = true;
30+
const stack = new StackLayout();
31+
stack.className = 'p-20';
32+
33+
const label = new Label();
34+
label.text = `${page.id}`;
35+
label.className = 'h2 text-center';
36+
stack.addChild(label);
37+
38+
const addButton = (text: string, cls: string, onTap: () => void) => {
39+
const btn = new Button();
40+
btn.text = text;
41+
btn.className = cls;
42+
btn.on('tap', onTap);
43+
stack.addChild(btn);
44+
};
45+
46+
addButton(`Push page ${depth + 1}`, 'btn btn-primary', () => {
47+
log(`push from ${page.id}: ${describe(frame)}`);
48+
frame.navigate({ create: () => makePage(frame, depth + 1) });
49+
log(`after push: ${describe(frame)}`);
50+
});
51+
addButton(`Push page ${depth + 1} (animated=false)`, 'btn btn-primary', () => {
52+
log(`push (no anim) from ${page.id}: ${describe(frame)}`);
53+
frame.navigate({ create: () => makePage(frame, depth + 1), animated: false });
54+
log(`after push: ${describe(frame)}`);
55+
});
56+
addButton('Go back', 'btn btn-outline', () => {
57+
log(`goBack from ${page.id}: canGoBack=${frame.canGoBack()} ${describe(frame)}`);
58+
frame.goBack();
59+
log(`after goBack: ${describe(frame)}`);
60+
});
61+
addButton('Log status', 'btn btn-outline', () => {
62+
log(`status ${frame.id}: ${describe(frame)} topmost=${Frame.topmost()?.id}`);
63+
});
64+
addButton('callLoaded() workaround', 'btn btn-outline', () => {
65+
frame.callLoaded();
66+
log(`after callLoaded: ${describe(frame)}`);
67+
});
68+
registry.frames.forEach((f, i) => {
69+
addButton(`Select tab ${TITLES[i]}`, 'btn btn-outline', () => {
70+
log(`programmatic select tab ${i} (${TITLES[i]}) from ${page.id}; topmost=${Frame.topmost()?.id}`);
71+
registry.tabView.selectedIndex = i;
72+
log(`after select: ${TITLES[i]} ${describe(registry.frames[i])} topmost=${Frame.topmost()?.id}`);
73+
});
74+
});
75+
addButton('Frame.topmost().navigate()', 'btn btn-outline', () => {
76+
const top = Frame.topmost() as Frame;
77+
log(`topmost navigate: topmost=${top?.id} (this page's frame=${frame.id}) before ${describe(top)}`);
78+
top.navigate({ create: () => makePage(top, (top.backStack.length || 0) + 2) });
79+
log(`after topmost navigate: ${describe(top)}`);
80+
});
81+
addButton('Frame.topmost().goBack()', 'btn btn-outline', () => {
82+
const top = Frame.topmost() as Frame;
83+
log(`topmost goBack: topmost=${top?.id} canGoBack=${top?.canGoBack()} ${describe(top)}`);
84+
top.goBack();
85+
log(`after topmost goBack: ${describe(top)}`);
86+
});
87+
addButton('Reassign items (new array)', 'btn btn-outline', () => {
88+
const tv = registry.tabView;
89+
log(`reassign items: selectedIndex=${tv.selectedIndex} before ${describe(frame)}`);
90+
tv.items = [...tv.items];
91+
log(`after reassign: selectedIndex=${tv.selectedIndex} ${describe(frame)} topmost=${Frame.topmost()?.id}`);
92+
});
93+
addButton('Log all frames', 'btn btn-outline', () => {
94+
registry.frames.forEach((f) => log(` ${f.id}: ${describe(f)}`));
95+
log(
96+
` topmost=${Frame.topmost()?.id} stack=[${(Frame as any)
97+
._stack()
98+
.map((f: Frame) => f.id)
99+
.join(', ')}]`,
100+
);
101+
});
102+
addButton('Select next tab + push there', 'btn btn-outline', () => {
103+
const n = registry.frames.length;
104+
const i = (registry.tabView.selectedIndex + 1) % n;
105+
const target = registry.frames[i];
106+
log(`select next tab ${i} + push: before ${describe(target)}`);
107+
registry.tabView.selectedIndex = i;
108+
log(`after select: ${describe(target)} topmost=${Frame.topmost()?.id}`);
109+
target.navigate({ create: () => makePage(target, (target.backStack.length || 0) + 2) });
110+
log(`after push on ${target.id}: ${describe(target)}`);
111+
});
112+
addButton('Push + switch tab', 'btn btn-outline', () => {
113+
const n = registry.frames.length;
114+
const i = (registry.tabView.selectedIndex + 1) % n;
115+
log(`push on ${frame.id} then switch to tab ${i}`);
116+
frame.navigate({ create: () => makePage(frame, depth + 1) });
117+
registry.tabView.selectedIndex = i;
118+
log(`after push+switch: ${describe(frame)} topmost=${Frame.topmost()?.id}`);
119+
});
120+
121+
const scroll = new ScrollView();
122+
scroll.content = stack;
123+
page.content = scroll;
124+
page.on('loaded', () => log(`${page.id} loaded; frame ${describe(frame)}`));
125+
page.on('unloaded', () => log(`${page.id} unloaded; frame ${describe(frame)}`));
126+
page.on('navigatedTo', () => log(`${page.id} navigatedTo; frame ${describe(frame)}`));
127+
return page;
128+
}
129+
130+
export function buildTabView(opts: NestedTabFrameOptions): TabView {
131+
Trace.enable();
132+
Trace.addCategories(Trace.categories.concat(Trace.categories.Navigation, Trace.categories.Transition, Trace.categories.NativeLifecycle));
133+
134+
const tabView = new TabView();
135+
if (opts.androidOffscreenTabLimit !== undefined) {
136+
tabView.androidOffscreenTabLimit = opts.androidOffscreenTabLimit;
137+
}
138+
registry.tabView = tabView;
139+
registry.frames = [];
140+
const items: TabViewItem[] = [];
141+
for (const title of TITLES.slice(0, opts.tabCount)) {
142+
const item = new TabViewItem();
143+
item.title = title;
144+
const frame = new Frame();
145+
frame.id = `frame-${title}`;
146+
frame.on('loaded', () => {
147+
log(`${frame.id} loaded: ${describe(frame)}`);
148+
if (!frame.currentPage && !(frame as any)._executingContext && (frame as any)._navigationQueue.length === 0) {
149+
log(`${frame.id} mounting initial page`);
150+
frame.navigate({ create: () => makePage(frame, 1) });
151+
}
152+
});
153+
frame.on('unloaded', () => log(`${frame.id} unloaded: ${describe(frame)}`));
154+
item.view = frame;
155+
items.push(item);
156+
registry.frames.push(frame);
157+
}
158+
tabView.items = items;
159+
tabView.on('selectedIndexChanged', (a: any) => log(`tab selectedIndexChanged ${a.oldIndex} -> ${a.newIndex}`));
160+
tabView.on('loaded', () => log(`tabView loaded`));
161+
tabView.on('unloaded', () => log(`tabView unloaded`));
162+
return tabView;
163+
}
164+
165+
export function setupPage(page: Page, opts: NestedTabFrameOptions) {
166+
log(`---- setup ${JSON.stringify(opts)} ----`);
167+
page.content = buildTabView(opts);
168+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { EventData, Page } from '@nativescript/core';
2+
import { setupPage } from './nested-tab-frame-shared';
3+
4+
export function navigatingTo(args: EventData) {
5+
setupPage(<Page>args.object, { tabCount: 2 });
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
2+
<Page.actionBar>
3+
<ActionBar title="Nested Tab Frame" icon="" class="action-bar">
4+
</ActionBar>
5+
</Page.actionBar>
6+
</Page>

‎packages/core/ui/core/view-base/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,15 @@ export abstract class ViewBase extends Observable {
10751075
}
10761076
}
10771077

1078+
/**
1079+
* Whether a child is the one currently shown. Containers that keep several children
1080+
* alive but show one at a time (a TabView's items) override it for the hidden ones.
1081+
* @param child A direct child of this view.
1082+
*/
1083+
public _isChildPresented(child: ViewBase): boolean {
1084+
return true;
1085+
}
1086+
10781087
/**
10791088
* Core logic for removing a child view from this instance. Used by the framework to handle lifecycle events more centralized. Do not use outside the UI Stack implementation.
10801089
*/

‎packages/core/ui/frame/frame-common.spec.ts‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { setActiveWindow } from '../../application/helpers-common';
55
import type { NativeWindow } from '../../native-window';
66
import type { BackstackEntry } from './frame-interfaces';
77
import { NavigationType } from './frame-interfaces';
8+
import { View } from '../core/view';
9+
import { ViewBase } from '../core/view-base';
10+
import { TabViewBase, TabViewItemBase } from '../tab-view/tab-view-common';
811

912
/**
1013
* `FrameBase` is used directly instead of the platform `Frame`: the navigation queue and the
@@ -256,3 +259,134 @@ describe('FrameBase.topmost', () => {
256259
expect(frameStack).toEqual([scoped, other]);
257260
});
258261
});
262+
263+
/** A container that shows a single child, like a TabView does with its items. */
264+
class SingleChildHost extends ViewBase {
265+
public shown: ViewBase;
266+
267+
public _isChildPresented(child: ViewBase): boolean {
268+
return child === this.shown;
269+
}
270+
}
271+
272+
class TabViewItem extends TabViewItemBase {
273+
public _update() {
274+
// no native tab to refresh in the common layer
275+
}
276+
}
277+
278+
function createTabItem(frame: FrameBase): TabViewItem {
279+
const item = new TabViewItem();
280+
item.view = frame;
281+
282+
return item;
283+
}
284+
285+
function stackIds(): string[] {
286+
return frameStack.map((frame) => frame.id);
287+
}
288+
289+
describe('frame stack presentation', () => {
290+
afterEach(() => {
291+
frameStack.splice(0).forEach((frame) => (frame._isInFrameStack = false));
292+
});
293+
294+
it('puts a frame nobody hides on top', () => {
295+
const host = new SingleChildHost();
296+
const frame = new FrameBase();
297+
host._addView(frame);
298+
host.shown = frame;
299+
300+
frame._pushInFrameStack();
301+
302+
expect(FrameBase.topmost()).toBe(frame);
303+
});
304+
305+
it('keeps a hidden frame in the stack without making it topmost', () => {
306+
const shownFrame = new FrameBase();
307+
shownFrame.id = 'shown';
308+
const hiddenFrame = new FrameBase();
309+
hiddenFrame.id = 'hidden';
310+
const host = new SingleChildHost();
311+
host._addView(shownFrame);
312+
host._addView(hiddenFrame);
313+
host.shown = shownFrame;
314+
shownFrame._pushInFrameStack();
315+
316+
hiddenFrame._pushInFrameStack();
317+
318+
expect(FrameBase.topmost()).toBe(shownFrame);
319+
expect(FrameBase.getFrameById('hidden')).toBe(hiddenFrame);
320+
expect(stackIds()).toEqual(['hidden', 'shown']);
321+
});
322+
323+
it('leaves a hidden frame where it is when it is already in the stack', () => {
324+
const shownFrame = new FrameBase();
325+
shownFrame.id = 'shown';
326+
const hiddenFrame = new FrameBase();
327+
hiddenFrame.id = 'hidden';
328+
const host = new SingleChildHost();
329+
host._addView(shownFrame);
330+
host._addView(hiddenFrame);
331+
host.shown = hiddenFrame;
332+
hiddenFrame._pushInFrameStack();
333+
host.shown = shownFrame;
334+
shownFrame._pushInFrameStack();
335+
336+
hiddenFrame._pushInFrameStack();
337+
338+
expect(stackIds()).toEqual(['hidden', 'shown']);
339+
});
340+
341+
it('does not let a frame hidden further up the tree reach the top', () => {
342+
const host = new SingleChildHost();
343+
const shownPane = new View();
344+
const hiddenPane = new View();
345+
host._addView(shownPane);
346+
host._addView(hiddenPane);
347+
host.shown = shownPane;
348+
const shownFrame = new FrameBase();
349+
const hiddenFrame = new FrameBase();
350+
shownPane._addView(shownFrame);
351+
hiddenPane._addView(hiddenFrame);
352+
shownFrame._pushInFrameStack();
353+
354+
hiddenFrame._pushInFrameStack();
355+
356+
expect(FrameBase.topmost()).toBe(shownFrame);
357+
});
358+
359+
it('keeps the selected tab frame topmost while a preloaded tab frame navigates', () => {
360+
const tabView = new TabViewBase();
361+
const homeFrame = new FrameBase();
362+
homeFrame.id = 'home';
363+
const demosFrame = new FrameBase();
364+
demosFrame.id = 'demos';
365+
tabView.items = [createTabItem(homeFrame), createTabItem(demosFrame)];
366+
tabView.selectedIndex = 0;
367+
homeFrame.navigate({ create: () => new View() });
368+
369+
demosFrame.navigate({ create: () => new View() });
370+
371+
expect(FrameBase.topmost()).toBe(homeFrame);
372+
expect(FrameBase.getFrameById('demos')).toBe(demosFrame);
373+
});
374+
375+
it('promotes the frame of a tab once that tab is selected', () => {
376+
const tabView = new TabViewBase();
377+
const homeFrame = new FrameBase();
378+
homeFrame.id = 'home';
379+
const demosFrame = new FrameBase();
380+
demosFrame.id = 'demos';
381+
tabView.items = [createTabItem(homeFrame), createTabItem(demosFrame)];
382+
tabView.selectedIndex = 0;
383+
homeFrame.navigate({ create: () => new View() });
384+
demosFrame.navigate({ create: () => new View() });
385+
386+
tabView.selectedIndex = 1;
387+
demosFrame._pushInFrameStackRecursive();
388+
389+
expect(FrameBase.topmost()).toBe(demosFrame);
390+
expect(stackIds()).toEqual(['home', 'demos']);
391+
});
392+
});

0 commit comments

Comments
 (0)