Skip to content
Open
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
51 changes: 50 additions & 1 deletion apps/automated/src/ui/frame/frame-tests.android.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as TKUnit from '../../tk-unit';
import { PercentLength, unsetValue, Frame } from '@nativescript/core';
import { PercentLength, unsetValue, Frame, Page } from '@nativescript/core';
export * from './frame-tests-common';

export function test_percent_width_and_height_set_to_page_support() {
Expand Down Expand Up @@ -62,3 +62,52 @@ export function test_percent_margin_set_to_page_support() {
TKUnit.assertTrue(PercentLength.equals(currentPage.marginRight, 0));
TKUnit.assertTrue(PercentLength.equals(currentPage.marginBottom, 0));
}

export function test_snapshot_back_preview_preserves_page_ownership() {
if (android.os.Build.VERSION.SDK_INT < 34) return;
const frame = Frame.topmost();
frame.navigate({ create: () => new Page(), clearHistory: true, animated: false });
TKUnit.waitUntilReady(() => frame.navigationQueueIsEmpty() && frame.currentPage.isLayoutValid, 15);
const previous = frame.currentPage;
frame.navigate({ create: () => new Page(), animated: false });
TKUnit.waitUntilReady(() => frame.navigationQueueIsEmpty() && frame.currentPage.isLayoutValid, 15);
const current = frame.currentPage;
const session = frame.beginBackPreview();
TKUnit.assertTrue(!!session, 'A ready Android Frame supplies a preview');
try {
session.update(0.5, 'left');
TKUnit.assertTrue(!previous.nativeViewProtected.getParent(), 'Preview never reparents previous Page');
TKUnit.assertEqual(frame.currentPage, current, 'Preview never changes current Page');
TKUnit.assertEqual(frame.backStack.length, 1, 'Preview never changes history');
session.dispose();
TKUnit.assertEqual(current.nativeViewProtected.getScaleX(), 1, 'Disposal restores native scale');
TKUnit.assertEqual(session.snapshotBytes, 0, 'Disposal releases the bitmap');
const interrupted = frame.beginBackPreview();
interrupted.update(0.5, 'right');
frame.goBack();
TKUnit.waitUntilReady(() => frame.navigationQueueIsEmpty(), 15);
TKUnit.assertFalse(interrupted.active, 'Frame navigation disposes the preview');
TKUnit.assertEqual(frame.currentPage, previous, 'Native pop restores the original Page');
} finally {
session.dispose();
}
}

export async function test_back_after_activity_recreation() {
const frame = Frame.topmost();
frame.navigate({ create: () => new Page(), clearHistory: true, animated: false });
TKUnit.waitUntilReady(() => frame.navigationQueueIsEmpty(), 15);
const previous = frame.currentPage;
frame.navigate({ create: () => new Page(), animated: false });
TKUnit.waitUntilReady(() => frame.navigationQueueIsEmpty(), 15);
const activity = frame.android.activity;
activity.recreate();
const deadline = Date.now() + 15000;
while (frame.android.activity === activity || !frame.isLoaded || !frame.currentPage.isLoaded) {
TKUnit.assertTrue(Date.now() < deadline, 'Activity recreated and Frame loaded');
await new Promise((resolve) => setTimeout(resolve, 100));
}
frame.goBack();
TKUnit.waitUntilReady(() => frame.navigationQueueIsEmpty(), 15);
TKUnit.assertEqual(frame.currentPage, previous, 'Back resumes after early native attachment');
}
45 changes: 45 additions & 0 deletions apps/toolbox/src/pages/back-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Application, Button, Frame, Label, Page, StackLayout } from '@nativescript/core';
import type { FrameBackPreview } from '@nativescript/core';

export function createPage(): Page {
const page = new Page();
page.actionBar.title = 'Frame back preview prototype';
const content = new StackLayout();
content.padding = 24;
const status = new Label();
status.text = 'Push a second page, then preview or recreate the Activity.';
status.textWrap = true;
content.addChild(status);
let session: FrameBackPreview;
const button = (text: string, run: () => void) => {
const view = new Button();
view.text = text;
view.on('tap', run);
content.addChild(view);
};
button('Push', () => page.frame.navigate({ create: createPage }));
button('Preview 50%', () => {
session?.dispose();
session = page.frame.beginBackPreview({
onDispose: () => {
status.text = 'Preview disposed';
},
});
session?.update(0.5, 'left');
status.text = session ? `Snapshot: ${session.snapshotBytes} bytes` : 'No supported preview';
});
button('Cancel', () => session?.cancel());
button('Finish and Back', () => {
void session?.finish().then((completed) => {
if (completed && session.active) page.frame.goBack();
});
});
button('Recreate Activity', () => {
if (__ANDROID__) Application.android.foregroundActivity.recreate();
});
page.on('loaded', () => {
if (session && !session.active) status.text = 'Recreated: old preview disposed';
});
page.content = content;
return page;
}
145 changes: 145 additions & 0 deletions packages/core/ui/frame/back-preview.android.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type { Frame, FrameBackPreview, FrameBackPreviewOptions } from '.';

const MAX_PIXELS = 4 * 1024 * 1024;

function containsSurface(view: android.view.View): boolean {
if (view instanceof android.view.SurfaceView || view instanceof android.view.TextureView) return true;
if (view instanceof android.view.ViewGroup) {
for (let i = 0; i < view.getChildCount(); i++) {
if (containsSurface(view.getChildAt(i))) return true;
}
}
return false;
}

export function createBackPreview(frame: Frame, options: FrameBackPreviewOptions): FrameBackPreview | undefined {
let top = frame.currentPage?.nativeViewProtected as android.view.View;
const below = frame.backStack.at(-1)?.resolvedPage.nativeViewProtected as android.view.View;
let container = frame.nativeViewProtected as android.view.ViewGroup;
if (!top || !below || !container || below.getParent() || top.getParent() !== container) return;
const width = below.getWidth();
const height = below.getHeight();
if (width <= 0 || height <= 0 || width * height > MAX_PIXELS || containsSurface(below)) return;
const window = frame.android.activity?.getWindow();
if (!window || (window.getAttributes().flags & android.view.WindowManager.LayoutParams.FLAG_SECURE) !== 0) return;

let bitmap: android.graphics.Bitmap;
let image: android.widget.ImageView;
try {
bitmap = android.graphics.Bitmap.createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888);
below.draw(new android.graphics.Canvas(bitmap));
image = new android.widget.ImageView(container.getContext());
image.setImageBitmap(bitmap);
image.setImportantForAccessibility(android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
image.setClickable(false);
image.setFocusable(false);
container.addView(image, 0, new org.nativescript.widgets.CommonLayoutParams());
image.measure(android.view.View.MeasureSpec.makeMeasureSpec(width, android.view.View.MeasureSpec.EXACTLY), android.view.View.MeasureSpec.makeMeasureSpec(height, android.view.View.MeasureSpec.EXACTLY));
image.layout(below.getLeft(), below.getTop(), below.getLeft() + width, below.getTop() + height);
} catch {
if (image?.getParent() === container) container.removeView(image);
image?.setImageDrawable(null);
bitmap?.recycle();
return;
}

const saved = { x: top.getTranslationX(), scaleX: top.getScaleX(), scaleY: top.getScaleY(), alpha: top.getAlpha(), elevation: top.getElevation(), background: top.getBackground() };
if (!saved.background) {
const color = new android.util.TypedValue();
const found = container.getContext().getTheme().resolveAttribute(android.R.attr.colorBackground, color, true);
top.setBackground(new android.graphics.drawable.ColorDrawable(found ? color.data : -1));
}
top.setElevation(saved.elevation + 8 * container.getResources().getDisplayMetrics().density);
let phase: 'dragging' | 'finishing' | 'finished' | 'cancelling' | 'disposed' = 'dragging';
let direction = 1;
let resolveFinish: (completed: boolean) => void;
let finishPromise: Promise<boolean>;
const frameWidth = container.getWidth();
const dispose = () => {
if (phase === 'disposed') return;
phase = 'disposed';
top.animate().cancel();
image.animate().cancel();
top.setTranslationX(saved.x);
top.setScaleX(saved.scaleX);
top.setScaleY(saved.scaleY);
top.setAlpha(saved.alpha);
top.setElevation(saved.elevation);
top.setBackground(saved.background);
if (image.getParent() === container) container.removeView(image);
image.setImageDrawable(null);
// RenderThread can still hold the bitmap from the previous frame.
bitmap = null;
resolveFinish?.(false);
resolveFinish = undefined;
const onDispose = options.onDispose;
options = {};
top = null;
image = null;
container = null;
saved.background = null;
onDispose?.();
};
return {
version: 1,
get active() {
return phase !== 'disposed';
},
get snapshotBytes() {
return bitmap?.getAllocationByteCount() ?? 0;
},
update(progress, edge) {
if (phase !== 'dragging' || !Number.isFinite(progress)) return;
direction = edge === 'right' ? -1 : 1;
const value = Math.max(0, Math.min(1, progress));
top.setScaleX(saved.scaleX * (1 - 0.1 * value));
top.setScaleY(saved.scaleY * (1 - 0.1 * value));
top.setTranslationX(saved.x + direction * 0.05 * frameWidth * value);
image.setTranslationX(-direction * 0.15 * frameWidth * (1 - value));
},
cancel() {
if (phase === 'disposed' || phase === 'cancelling') return;
phase = 'cancelling';
top.animate().cancel();
image.animate().cancel();
resolveFinish?.(false);
resolveFinish = undefined;
top.animate().translationX(saved.x).scaleX(saved.scaleX).scaleY(saved.scaleY).alpha(saved.alpha).setDuration(200);
image
.animate()
.translationX(-direction * 0.15 * frameWidth)
.setDuration(200)
.withEndAction(new java.lang.Runnable({ run: dispose }));
},
finish() {
if (phase === 'finished') return Promise.resolve(true);
if (phase === 'finishing') return finishPromise;
if (phase !== 'dragging') return Promise.resolve(false);
phase = 'finishing';
finishPromise = new Promise((resolve) => {
resolveFinish = resolve;
});
top
.animate()
.translationX(saved.x + direction * frameWidth)
.alpha(0)
.setDuration(200);
image
.animate()
.translationX(0)
.setDuration(200)
.withEndAction(
new java.lang.Runnable({
run() {
if (phase !== 'finishing') return;
phase = 'finished';
resolveFinish?.(true);
resolveFinish = undefined;
},
}),
);
return finishPromise;
},
dispose,
};
}
Loading
Loading