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
2 changes: 2 additions & 0 deletions apps/toolbox/src/main-page.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
<Button text="list-page-sticky" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="list-page-sticky-templates" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="multiple-scenes" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="nested-tab-frame" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="nested-tab-frame-4tabs" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="root-layout" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="scroll-view" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="sliders" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
Expand Down
6 changes: 6 additions & 0 deletions apps/toolbox/src/pages/nested-tab-frame-4tabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { EventData, Page } from '@nativescript/core';
import { setupPage } from './nested-tab-frame-shared';

export function navigatingTo(args: EventData) {
setupPage(<Page>args.object, { tabCount: 4 });
}
6 changes: 6 additions & 0 deletions apps/toolbox/src/pages/nested-tab-frame-4tabs.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
<Page.actionBar>
<ActionBar title="Nested Tab Frame (4 tabs)" icon="" class="action-bar">
</ActionBar>
</Page.actionBar>
</Page>
168 changes: 168 additions & 0 deletions apps/toolbox/src/pages/nested-tab-frame-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { Button, Frame, Label, Page, ScrollView, StackLayout, TabView, TabViewItem, Trace } from '@nativescript/core';

/**
* Playground for a Frame hosted as a TabViewItem view (issue #11444): every tab frame mounts
* its first page in `loaded`, and the inner pages expose the navigation and frame-stack
* probes used to reproduce the reported stalls.
*/
export interface NestedTabFrameOptions {
tabCount: number;
androidOffscreenTabLimit?: number;
}

const TITLES = ['Home', 'Demos', 'Third', 'Fourth'];

export const registry = { tabView: null as TabView, frames: [] as Frame[] };

export function log(msg: string) {
console.log(`[NTF] ${msg}`);
}

export function describe(frame: Frame): string {
const f = frame as any;
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}`;
}

export function makePage(frame: Frame, depth: number): Page {
const page = new Page();
page.id = `${frame.id}-page${depth}`;
page.actionBarHidden = true;
const stack = new StackLayout();
stack.className = 'p-20';

const label = new Label();
label.text = `${page.id}`;
label.className = 'h2 text-center';
stack.addChild(label);

const addButton = (text: string, cls: string, onTap: () => void) => {
const btn = new Button();
btn.text = text;
btn.className = cls;
btn.on('tap', onTap);
stack.addChild(btn);
};

addButton(`Push page ${depth + 1}`, 'btn btn-primary', () => {
log(`push from ${page.id}: ${describe(frame)}`);
frame.navigate({ create: () => makePage(frame, depth + 1) });
log(`after push: ${describe(frame)}`);
});
addButton(`Push page ${depth + 1} (animated=false)`, 'btn btn-primary', () => {
log(`push (no anim) from ${page.id}: ${describe(frame)}`);
frame.navigate({ create: () => makePage(frame, depth + 1), animated: false });
log(`after push: ${describe(frame)}`);
});
addButton('Go back', 'btn btn-outline', () => {
log(`goBack from ${page.id}: canGoBack=${frame.canGoBack()} ${describe(frame)}`);
frame.goBack();
log(`after goBack: ${describe(frame)}`);
});
addButton('Log status', 'btn btn-outline', () => {
log(`status ${frame.id}: ${describe(frame)} topmost=${Frame.topmost()?.id}`);
});
addButton('callLoaded() workaround', 'btn btn-outline', () => {
frame.callLoaded();
log(`after callLoaded: ${describe(frame)}`);
});
registry.frames.forEach((f, i) => {
addButton(`Select tab ${TITLES[i]}`, 'btn btn-outline', () => {
log(`programmatic select tab ${i} (${TITLES[i]}) from ${page.id}; topmost=${Frame.topmost()?.id}`);
registry.tabView.selectedIndex = i;
log(`after select: ${TITLES[i]} ${describe(registry.frames[i])} topmost=${Frame.topmost()?.id}`);
});
});
addButton('Frame.topmost().navigate()', 'btn btn-outline', () => {
const top = Frame.topmost() as Frame;
log(`topmost navigate: topmost=${top?.id} (this page's frame=${frame.id}) before ${describe(top)}`);
top.navigate({ create: () => makePage(top, (top.backStack.length || 0) + 2) });
log(`after topmost navigate: ${describe(top)}`);
});
addButton('Frame.topmost().goBack()', 'btn btn-outline', () => {
const top = Frame.topmost() as Frame;
log(`topmost goBack: topmost=${top?.id} canGoBack=${top?.canGoBack()} ${describe(top)}`);
top.goBack();
log(`after topmost goBack: ${describe(top)}`);
});
addButton('Reassign items (new array)', 'btn btn-outline', () => {
const tv = registry.tabView;
log(`reassign items: selectedIndex=${tv.selectedIndex} before ${describe(frame)}`);
tv.items = [...tv.items];
log(`after reassign: selectedIndex=${tv.selectedIndex} ${describe(frame)} topmost=${Frame.topmost()?.id}`);
});
addButton('Log all frames', 'btn btn-outline', () => {
registry.frames.forEach((f) => log(` ${f.id}: ${describe(f)}`));
log(
` topmost=${Frame.topmost()?.id} stack=[${(Frame as any)
._stack()
.map((f: Frame) => f.id)
.join(', ')}]`,
);
});
addButton('Select next tab + push there', 'btn btn-outline', () => {
const n = registry.frames.length;
const i = (registry.tabView.selectedIndex + 1) % n;
const target = registry.frames[i];
log(`select next tab ${i} + push: before ${describe(target)}`);
registry.tabView.selectedIndex = i;
log(`after select: ${describe(target)} topmost=${Frame.topmost()?.id}`);
target.navigate({ create: () => makePage(target, (target.backStack.length || 0) + 2) });
log(`after push on ${target.id}: ${describe(target)}`);
});
addButton('Push + switch tab', 'btn btn-outline', () => {
const n = registry.frames.length;
const i = (registry.tabView.selectedIndex + 1) % n;
log(`push on ${frame.id} then switch to tab ${i}`);
frame.navigate({ create: () => makePage(frame, depth + 1) });
registry.tabView.selectedIndex = i;
log(`after push+switch: ${describe(frame)} topmost=${Frame.topmost()?.id}`);
});

const scroll = new ScrollView();
scroll.content = stack;
page.content = scroll;
page.on('loaded', () => log(`${page.id} loaded; frame ${describe(frame)}`));
page.on('unloaded', () => log(`${page.id} unloaded; frame ${describe(frame)}`));
page.on('navigatedTo', () => log(`${page.id} navigatedTo; frame ${describe(frame)}`));
return page;
}

export function buildTabView(opts: NestedTabFrameOptions): TabView {
Trace.enable();
Trace.addCategories(Trace.categories.concat(Trace.categories.Navigation, Trace.categories.Transition, Trace.categories.NativeLifecycle));

const tabView = new TabView();
if (opts.androidOffscreenTabLimit !== undefined) {
tabView.androidOffscreenTabLimit = opts.androidOffscreenTabLimit;
}
registry.tabView = tabView;
registry.frames = [];
const items: TabViewItem[] = [];
for (const title of TITLES.slice(0, opts.tabCount)) {
const item = new TabViewItem();
item.title = title;
const frame = new Frame();
frame.id = `frame-${title}`;
frame.on('loaded', () => {
log(`${frame.id} loaded: ${describe(frame)}`);
if (!frame.currentPage && !(frame as any)._executingContext && (frame as any)._navigationQueue.length === 0) {
log(`${frame.id} mounting initial page`);
frame.navigate({ create: () => makePage(frame, 1) });
}
});
frame.on('unloaded', () => log(`${frame.id} unloaded: ${describe(frame)}`));
item.view = frame;
items.push(item);
registry.frames.push(frame);
}
tabView.items = items;
tabView.on('selectedIndexChanged', (a: any) => log(`tab selectedIndexChanged ${a.oldIndex} -> ${a.newIndex}`));
tabView.on('loaded', () => log(`tabView loaded`));
tabView.on('unloaded', () => log(`tabView unloaded`));
return tabView;
}

export function setupPage(page: Page, opts: NestedTabFrameOptions) {
log(`---- setup ${JSON.stringify(opts)} ----`);
page.content = buildTabView(opts);
}
6 changes: 6 additions & 0 deletions apps/toolbox/src/pages/nested-tab-frame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { EventData, Page } from '@nativescript/core';
import { setupPage } from './nested-tab-frame-shared';

export function navigatingTo(args: EventData) {
setupPage(<Page>args.object, { tabCount: 2 });
}
6 changes: 6 additions & 0 deletions apps/toolbox/src/pages/nested-tab-frame.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
<Page.actionBar>
<ActionBar title="Nested Tab Frame" icon="" class="action-bar">
</ActionBar>
</Page.actionBar>
</Page>
9 changes: 9 additions & 0 deletions packages/core/ui/core/view-base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,15 @@ export abstract class ViewBase extends Observable {
}
}

/**
* Whether a child is the one currently shown. Containers that keep several children
* alive but show one at a time (a TabView's items) override it for the hidden ones.
* @param child A direct child of this view.
*/
public _isChildPresented(child: ViewBase): boolean {
return true;
}

/**
* 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.
*/
Expand Down
134 changes: 134 additions & 0 deletions packages/core/ui/frame/frame-common.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { setActiveWindow } from '../../application/helpers-common';
import type { NativeWindow } from '../../native-window';
import type { BackstackEntry } from './frame-interfaces';
import { NavigationType } from './frame-interfaces';
import { View } from '../core/view';
import { ViewBase } from '../core/view-base';
import { TabViewBase, TabViewItemBase } from '../tab-view/tab-view-common';

/**
* `FrameBase` is used directly instead of the platform `Frame`: the navigation queue and the
Expand Down Expand Up @@ -256,3 +259,134 @@ describe('FrameBase.topmost', () => {
expect(frameStack).toEqual([scoped, other]);
});
});

/** A container that shows a single child, like a TabView does with its items. */
class SingleChildHost extends ViewBase {
public shown: ViewBase;

public _isChildPresented(child: ViewBase): boolean {
return child === this.shown;
}
}

class TabViewItem extends TabViewItemBase {
public _update() {
// no native tab to refresh in the common layer
}
}

function createTabItem(frame: FrameBase): TabViewItem {
const item = new TabViewItem();
item.view = frame;

return item;
}

function stackIds(): string[] {
return frameStack.map((frame) => frame.id);
}

describe('frame stack presentation', () => {
afterEach(() => {
frameStack.splice(0).forEach((frame) => (frame._isInFrameStack = false));
});

it('puts a frame nobody hides on top', () => {
const host = new SingleChildHost();
const frame = new FrameBase();
host._addView(frame);
host.shown = frame;

frame._pushInFrameStack();

expect(FrameBase.topmost()).toBe(frame);
});

it('keeps a hidden frame in the stack without making it topmost', () => {
const shownFrame = new FrameBase();
shownFrame.id = 'shown';
const hiddenFrame = new FrameBase();
hiddenFrame.id = 'hidden';
const host = new SingleChildHost();
host._addView(shownFrame);
host._addView(hiddenFrame);
host.shown = shownFrame;
shownFrame._pushInFrameStack();

hiddenFrame._pushInFrameStack();

expect(FrameBase.topmost()).toBe(shownFrame);
expect(FrameBase.getFrameById('hidden')).toBe(hiddenFrame);
expect(stackIds()).toEqual(['hidden', 'shown']);
});

it('leaves a hidden frame where it is when it is already in the stack', () => {
const shownFrame = new FrameBase();
shownFrame.id = 'shown';
const hiddenFrame = new FrameBase();
hiddenFrame.id = 'hidden';
const host = new SingleChildHost();
host._addView(shownFrame);
host._addView(hiddenFrame);
host.shown = hiddenFrame;
hiddenFrame._pushInFrameStack();
host.shown = shownFrame;
shownFrame._pushInFrameStack();

hiddenFrame._pushInFrameStack();

expect(stackIds()).toEqual(['hidden', 'shown']);
});

it('does not let a frame hidden further up the tree reach the top', () => {
const host = new SingleChildHost();
const shownPane = new View();
const hiddenPane = new View();
host._addView(shownPane);
host._addView(hiddenPane);
host.shown = shownPane;
const shownFrame = new FrameBase();
const hiddenFrame = new FrameBase();
shownPane._addView(shownFrame);
hiddenPane._addView(hiddenFrame);
shownFrame._pushInFrameStack();

hiddenFrame._pushInFrameStack();

expect(FrameBase.topmost()).toBe(shownFrame);
});

it('keeps the selected tab frame topmost while a preloaded tab frame navigates', () => {
const tabView = new TabViewBase();
const homeFrame = new FrameBase();
homeFrame.id = 'home';
const demosFrame = new FrameBase();
demosFrame.id = 'demos';
tabView.items = [createTabItem(homeFrame), createTabItem(demosFrame)];
tabView.selectedIndex = 0;
homeFrame.navigate({ create: () => new View() });

demosFrame.navigate({ create: () => new View() });

expect(FrameBase.topmost()).toBe(homeFrame);
expect(FrameBase.getFrameById('demos')).toBe(demosFrame);
});

it('promotes the frame of a tab once that tab is selected', () => {
const tabView = new TabViewBase();
const homeFrame = new FrameBase();
homeFrame.id = 'home';
const demosFrame = new FrameBase();
demosFrame.id = 'demos';
tabView.items = [createTabItem(homeFrame), createTabItem(demosFrame)];
tabView.selectedIndex = 0;
homeFrame.navigate({ create: () => new View() });
demosFrame.navigate({ create: () => new View() });

tabView.selectedIndex = 1;
demosFrame._pushInFrameStackRecursive();

expect(FrameBase.topmost()).toBe(demosFrame);
expect(stackIds()).toEqual(['home', 'demos']);
});
});
Loading
Loading