diff --git a/packages/core/ui/core/view/index.ios.ts b/packages/core/ui/core/view/index.ios.ts index f537018753..419a3b468b 100644 --- a/packages/core/ui/core/view/index.ios.ts +++ b/packages/core/ui/core/view/index.ios.ts @@ -56,6 +56,9 @@ export class View extends ViewCommon { private _isTransformed = false; private _privateFlags: number = PFLAG_LAYOUT_REQUIRED | PFLAG_FORCE_LAYOUT; private _cachedFrame: CGRect; + private _lastLayoutPosition: { frame: CGRect; left: number; top: number; right: number; bottom: number }; + private _framePosition: Position; + private _pendingFramePosition: Position; private _suspendCATransaction = false; /** * Native background states. @@ -87,6 +90,8 @@ export class View extends ViewCommon { super.disposeNativeView(); this._cachedFrame = null; + this._lastLayoutPosition = null; + this._framePosition = null; this._isLaidOut = false; this._isTransformed = false; } @@ -151,7 +156,7 @@ export class View extends ViewCommon { if (this.nativeViewProtected && SDK_VERSION > 10) { // on iOS 11+ it is possible to have a changed layout frame due to safe area insets // get the frame and adjust the position, so that onLayout works correctly - position = IOSHelper.getPositionFromFrame(this.nativeViewProtected.frame); + position = (setFrame && !this._isTransformed && this._framePosition) || IOSHelper.getPositionFromFrame(this.nativeViewProtected.frame); } else { position = { left, top, right, bottom }; } @@ -244,8 +249,11 @@ export class View extends ViewCommon { nativeView.frame = frame; const adjustedFrame = this.applySafeAreaInsets(frame); - if (adjustedFrame) { + if (adjustedFrame && adjustedFrame !== frame) { nativeView.frame = adjustedFrame; + this._framePosition = null; + } else { + this._framePosition = this._pendingFramePosition; } if (transform != null) { @@ -296,6 +304,11 @@ export class View extends ViewCommon { return; } + const last = this._lastLayoutPosition; + if (last && this._isLaidOut && last.frame === this._cachedFrame && last.left === left && last.top === top && last.right === right && last.bottom === bottom) { + return; + } + const nativeView = this.nativeViewProtected; const frame = IOSHelper.getFrameFromPosition({ left, @@ -303,7 +316,10 @@ export class View extends ViewCommon { right, bottom, }); + this._pendingFramePosition = IOSHelper.getPositionFromFramePosition(left, top, right, bottom); this._setNativeViewFrame(nativeView, frame); + this._pendingFramePosition = null; + this._lastLayoutPosition = { frame: this._cachedFrame, left, top, right, bottom }; } public _layoutParent() { @@ -360,7 +376,6 @@ export class View extends ViewCommon { } public getSafeAreaInsets(): Position { - const safeAreaInsets = this.nativeViewProtected && this.nativeViewProtected.safeAreaInsets; const insets = { left: 0, top: 0, right: 0, bottom: 0 }; if (this.iosIgnoreSafeArea) { return insets; @@ -370,6 +385,7 @@ export class View extends ViewCommon { if (IOSHelper.hasIOSManagedInsetAncestor(this)) { return insets; } + const safeAreaInsets = this.nativeViewProtected && this.nativeViewProtected.safeAreaInsets; if (safeAreaInsets) { insets.left = layout.round(layout.toDevicePixels(safeAreaInsets.left)); insets.top = layout.round(layout.toDevicePixels(safeAreaInsets.top)); diff --git a/packages/core/ui/core/view/view-helper/frame-position.spec.ts b/packages/core/ui/core/view/view-helper/frame-position.spec.ts new file mode 100644 index 0000000000..1e450bd7a0 --- /dev/null +++ b/packages/core/ui/core/view/view-helper/frame-position.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeAll } from 'vitest'; + +import { IOSHelper } from '.'; + +describe('IOSHelper.getPositionFromFramePosition', () => { + beforeAll(() => { + (global as any).CGRectMake ??= (x: number, y: number, width: number, height: number) => ({ origin: { x, y }, size: { width, height } }); + }); + + it('matches reading the position back from the frame made for it', () => { + const positions = [ + [0, 0, 100, 50], + [10.4, 20.6, 110.5, 70.49], + [-5, 3, 2, 1], + [30, 40, 20, 30], + [1234.5678, 0.0001, 2000.123, 99.999], + ]; + for (const [left, top, right, bottom] of positions) { + expect(IOSHelper.getPositionFromFramePosition(left, top, right, bottom)).toEqual(IOSHelper.getPositionFromFrame(IOSHelper.getFrameFromPosition({ left, top, right, bottom }))); + } + }); +}); diff --git a/packages/core/ui/core/view/view-helper/index.d.ts b/packages/core/ui/core/view/view-helper/index.d.ts index 19be342249..e4b6fce3d3 100644 --- a/packages/core/ui/core/view/view-helper/index.d.ts +++ b/packages/core/ui/core/view/view-helper/index.d.ts @@ -99,6 +99,9 @@ export namespace IOSHelper { export function layoutView(controller: any /* UIViewController */, owner: View): void; export function getPositionFromFrame(frame: any /* CGRect */): Position; export function getFrameFromPosition(position: Position, insets?: Position): any; /* CGRect */ + export function getPositionFromFramePosition(left: number, top: number, right: number, bottom: number): Position; + export function getPositionFromRect(x: number, y: number, width: number, height: number): Position; + export function getAvailablePositionsFromParent(view: View, width: number, height: number): { safeArea: Position; fullscreen: Position; inWindow: Position }; export function shrinkToSafeArea(view: View, frame: any /* CGRect */): any; /* CGRect */ export function expandBeyondSafeArea(view: View, frame: any /* CGRect */): any; /* CGRect */ export class UILayoutViewController { diff --git a/packages/core/ui/core/view/view-helper/index.ios.ts b/packages/core/ui/core/view/view-helper/index.ios.ts index fec7099137..0f7a496e97 100644 --- a/packages/core/ui/core/view/view-helper/index.ios.ts +++ b/packages/core/ui/core/view/view-helper/index.ios.ts @@ -506,6 +506,20 @@ export class IOSHelper { return { left, right, top, bottom }; } + static getPositionFromFramePosition(left: number, top: number, right: number, bottom: number): Position { + const x = layout.toDeviceIndependentPixels(left); + const y = layout.toDeviceIndependentPixels(top); + const width = Math.max(layout.toDeviceIndependentPixels(right - left), 0); + const height = Math.max(layout.toDeviceIndependentPixels(bottom - top), 0); + + return { + left: layout.round(layout.toDevicePixels(x)), + top: layout.round(layout.toDevicePixels(y)), + right: layout.round(layout.toDevicePixels(x + width)), + bottom: layout.round(layout.toDevicePixels(y + height)), + }; + } + static getFrameFromPosition(position: Position, insets?: Position): CGRect { insets = insets || { left: 0, top: 0, right: 0, bottom: 0 }; @@ -559,21 +573,24 @@ export class IOSHelper { } static expandBeyondSafeArea(view: View, frame: CGRect): CGRect { - const availableSpace = IOSHelper.getAvailableSpaceFromParent(view, frame); + const origin = frame.origin; + const size = frame.size; + const x = origin.x; + const y = origin.y; + const width = size.width; + const height = size.height; + + const availableSpace = IOSHelper.getAvailablePositionsFromParent(view, width, height); // Detached NS subtrees (e.g. TabView's `iosBottomAccessory`) have no safe-area // reference rects to expand against; leave the frame as-is. - if (!availableSpace || !availableSpace.safeArea || !availableSpace.fullscreen) { + if (!availableSpace) { return frame; } - const safeArea = availableSpace.safeArea; - const fullscreen = availableSpace.fullscreen; - const inWindow = availableSpace.inWindow; - - const position = IOSHelper.getPositionFromFrame(frame); - const safeAreaPosition = IOSHelper.getPositionFromFrame(safeArea); - const fullscreenPosition = IOSHelper.getPositionFromFrame(fullscreen); - const inWindowPosition = IOSHelper.getPositionFromFrame(inWindow); + const safeAreaPosition = availableSpace.safeArea; + const fullscreenPosition = availableSpace.fullscreen; + const inWindowPosition = availableSpace.inWindow; + const position = IOSHelper.getPositionFromRect(x, y, width, height); const adjustedPosition = position; if (position.left && inWindowPosition.left <= safeAreaPosition.left) { @@ -592,7 +609,15 @@ export class IOSHelper { adjustedPosition.bottom += fullscreenPosition.bottom - inWindowPosition.bottom; } - const adjustedFrame = CGRectMake(layout.toDeviceIndependentPixels(adjustedPosition.left), layout.toDeviceIndependentPixels(adjustedPosition.top), layout.toDeviceIndependentPixels(adjustedPosition.right - adjustedPosition.left), layout.toDeviceIndependentPixels(adjustedPosition.bottom - adjustedPosition.top)); + const adjustedX = layout.toDeviceIndependentPixels(adjustedPosition.left); + const adjustedY = layout.toDeviceIndependentPixels(adjustedPosition.top); + const adjustedWidth = layout.toDeviceIndependentPixels(adjustedPosition.right - adjustedPosition.left); + const adjustedHeight = layout.toDeviceIndependentPixels(adjustedPosition.bottom - adjustedPosition.top); + if (adjustedX === x && adjustedY === y && adjustedWidth === width && adjustedHeight === height) { + return frame; + } + + const adjustedFrame = CGRectMake(adjustedX, adjustedY, adjustedWidth, adjustedHeight); if (Trace.isEnabled()) { Trace.write(view + ' :expandBeyondSafeArea: ' + JSON.stringify(IOSHelper.getPositionFromFrame(adjustedFrame)), Trace.categories.Layout); @@ -601,6 +626,81 @@ export class IOSHelper { return adjustedFrame; } + static getPositionFromRect(x: number, y: number, width: number, height: number): Position { + return { + left: layout.round(layout.toDevicePixels(x)), + top: layout.round(layout.toDevicePixels(y)), + right: layout.round(layout.toDevicePixels(x + width)), + bottom: layout.round(layout.toDevicePixels(y + height)), + }; + } + + static getAvailablePositionsFromParent(view: View, width: number, height: number): { safeArea: Position; fullscreen: Position; inWindow: Position } { + if (!view) { + return; + } + + let scrollView: UIScrollView = null; + let viewControllerView: UIView = null; + + if (view.viewController) { + viewControllerView = view.viewController.view; + } else { + let parent = view.parent as View; + while (parent && !parent.viewController && !(parent.nativeViewProtected instanceof UIScrollView)) { + parent = parent.parent as View; + } + + if (parent) { + if (parent.nativeViewProtected instanceof UIScrollView) { + scrollView = parent.nativeViewProtected; + } else if (parent.viewController) { + viewControllerView = parent.viewController.view; + } + } + } + + let fullscreen: Position; + let safeArea: Position; + let controllerX = 0; + let controllerY = 0; + + if (viewControllerView) { + safeArea = IOSHelper.getPositionFromFrame(viewControllerView.safeAreaLayoutGuide.layoutFrame); + fullscreen = IOSHelper.getPositionFromFrame(viewControllerView.frame); + const controllerInWindow = viewControllerView.convertPointToView(viewControllerView.bounds.origin, null); + controllerX = controllerInWindow.x; + controllerY = controllerInWindow.y; + } else if (scrollView) { + const insets = scrollView.safeAreaInsets; + const insetLeft = insets.left; + const insetTop = insets.top; + const contentSize = scrollView.contentSize; + const contentWidth = contentSize.width; + const contentHeight = contentSize.height; + safeArea = IOSHelper.getPositionFromRect(insetLeft, insetTop, contentWidth - insetLeft - insets.right, contentHeight - insetTop - insets.bottom); + fullscreen = IOSHelper.getPositionFromRect(0, 0, contentWidth, contentHeight); + } else { + return; + } + + const locationInWindow = view.getLocationInWindow(); + let inWindowLeft = locationInWindow.x - controllerX; + let inWindowTop = locationInWindow.y - controllerY; + + if (scrollView) { + const contentOffset = scrollView.contentOffset; + inWindowLeft += contentOffset.x; + inWindowTop += contentOffset.y; + } + + return { + safeArea, + fullscreen, + inWindow: IOSHelper.getPositionFromRect(inWindowLeft, inWindowTop, width, height), + }; + } + static getAvailableSpaceFromParent(view: View, frame: CGRect): { safeArea: CGRect; fullscreen: CGRect; inWindow: CGRect } { if (!view) { return; diff --git a/packages/core/ui/core/view/view-helper/safe-area-expansion.spec.ts b/packages/core/ui/core/view/view-helper/safe-area-expansion.spec.ts new file mode 100644 index 0000000000..f7710550a9 --- /dev/null +++ b/packages/core/ui/core/view/view-helper/safe-area-expansion.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeAll } from 'vitest'; + +import { IOSHelper } from '.'; +import { layout } from '../../../../utils/layout-helper'; + +type Rect = { origin: { x: number; y: number }; size: { width: number; height: number } }; +const rect = (x: number, y: number, width: number, height: number): Rect => ({ origin: { x, y }, size: { width, height } }); + +class FakeScrollView { + constructor( + public safeAreaInsets: { top: number; left: number; bottom: number; right: number }, + public contentSize: { width: number; height: number }, + public contentOffset: { x: number; y: number }, + ) {} +} + +class FakeControllerView { + constructor( + public frame: Rect, + private safeFrame: Rect, + private inWindow: { x: number; y: number }, + ) {} + get safeAreaLayoutGuide() { + return { layoutFrame: this.safeFrame }; + } + get bounds() { + return rect(0, 0, this.frame.size.width, this.frame.size.height); + } + convertPointToView() { + return this.inWindow; + } +} + +function expandBeyondSafeAreaBefore(view: any, frame: Rect): Rect { + const availableSpace = IOSHelper.getAvailableSpaceFromParent(view, frame); + if (!availableSpace || !availableSpace.safeArea || !availableSpace.fullscreen) { + return frame; + } + const position = IOSHelper.getPositionFromFrame(frame); + const safeAreaPosition = IOSHelper.getPositionFromFrame(availableSpace.safeArea); + const fullscreenPosition = IOSHelper.getPositionFromFrame(availableSpace.fullscreen); + const inWindowPosition = IOSHelper.getPositionFromFrame(availableSpace.inWindow); + const adjustedPosition = position; + if (position.left && inWindowPosition.left <= safeAreaPosition.left) { + adjustedPosition.left = fullscreenPosition.left; + } + if (position.top && inWindowPosition.top <= safeAreaPosition.top) { + adjustedPosition.top = fullscreenPosition.top; + } + if (inWindowPosition.right < fullscreenPosition.right && inWindowPosition.right >= safeAreaPosition.right + fullscreenPosition.left) { + adjustedPosition.right += fullscreenPosition.right - inWindowPosition.right; + } + if (inWindowPosition.bottom < fullscreenPosition.bottom && inWindowPosition.bottom >= safeAreaPosition.bottom + fullscreenPosition.top) { + adjustedPosition.bottom += fullscreenPosition.bottom - inWindowPosition.bottom; + } + + return rect(layout.toDeviceIndependentPixels(adjustedPosition.left), layout.toDeviceIndependentPixels(adjustedPosition.top), layout.toDeviceIndependentPixels(adjustedPosition.right - adjustedPosition.left), layout.toDeviceIndependentPixels(adjustedPosition.bottom - adjustedPosition.top)); +} + +function random(seed: number) { + return () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; + }; +} + +describe('IOSHelper.expandBeyondSafeArea', () => { + beforeAll(() => { + (global as any).CGRectMake ??= rect; + (global as any).UIScrollView ??= FakeScrollView; + }); + + function viewIn(container: { scrollView?: FakeScrollView; controllerView?: FakeControllerView }, location: { x: number; y: number }) { + const parent: any = container.scrollView ? { nativeViewProtected: container.scrollView } : { viewController: { view: container.controllerView } }; + return { parent: { parent }, getLocationInWindow: () => location } as any; + } + + it('matches the previous computation inside a scroll view and a view controller', () => { + const next = random(7); + const pick = (values: number[]) => values[Math.floor(next() * values.length)]; + let expanded = 0; + for (let i = 0; i < 2000; i++) { + const insets = { top: pick([0, 47, 59.5]), left: pick([0, 44]), bottom: pick([0, 34]), right: pick([0, 44]) }; + const width = pick([390, 430, 844]); + const height = pick([844, 932, 2000]); + const location = { x: pick([0, 0.5, 10, width - 100]), y: pick([0, 20, 47, height - 50]) }; + const frame = rect(pick([0, 3.3333, 10, 44]), pick([0, 16.6667, 47]), pick([100, width, 87.6667]), pick([50, height - 34, 33.3333])); + const container = next() < 0.5 ? { scrollView: new FakeScrollView(insets, { width, height }, { x: 0, y: pick([0, 120.5]) }) } : { controllerView: new FakeControllerView(rect(0, 0, width, height), rect(insets.left, insets.top, width - insets.left - insets.right, height - insets.top - insets.bottom), { x: 0, y: pick([0, 300]) }) }; + const view = viewIn(container, location); + + const expected = expandBeyondSafeAreaBefore(view, frame); + const actual = IOSHelper.expandBeyondSafeArea(view, frame as any) as unknown as Rect; + + expect([actual.origin.x, actual.origin.y, actual.size.width, actual.size.height]).toEqual([expected.origin.x, expected.origin.y, expected.size.width, expected.size.height]); + if (expected.origin.y !== frame.origin.y || expected.size.height !== frame.size.height || expected.size.width !== frame.size.width) { + expanded++; + } + } + expect(expanded).toBeGreaterThan(200); + }); + + it('returns the frame itself when nothing changes', () => { + const view = viewIn({ scrollView: new FakeScrollView({ top: 0, left: 0, bottom: 0, right: 0 }, { width: 400, height: 800 }, { x: 0, y: 0 }) }, { x: 100, y: 100 }); + const frame = rect(100, 100, 50, 50); + + expect(IOSHelper.expandBeyondSafeArea(view, frame as any)).toBe(frame); + }); + + it('leaves a detached view alone', () => { + const frame = rect(0, 0, 10, 10); + + expect(IOSHelper.expandBeyondSafeArea({ getLocationInWindow: () => ({ x: 0, y: 0 }) } as any, frame as any)).toBe(frame); + }); +}); diff --git a/packages/core/ui/label/index.ios.ts b/packages/core/ui/label/index.ios.ts index 86e948b957..20f5b8b7f0 100644 --- a/packages/core/ui/label/index.ios.ts +++ b/packages/core/ui/label/index.ios.ts @@ -113,10 +113,10 @@ export class Label extends TextBase implements LabelDefinition { const nativeSize = view.textRectForBoundsLimitedToNumberOfLines(CGRectMake(0, 0, widthMode === 0 /* layout.UNSPECIFIED */ ? Number.POSITIVE_INFINITY : layout.toDeviceIndependentPixels(width), heightMode === 0 /* layout.UNSPECIFIED */ ? Number.POSITIVE_INFINITY : layout.toDeviceIndependentPixels(height)), view.numberOfLines).size; - nativeSize.width = layout.round(layout.toDevicePixels(nativeSize.width)); - nativeSize.height = layout.round(layout.toDevicePixels(nativeSize.height)); - - return nativeSize; + return { + width: layout.round(layout.toDevicePixels(nativeSize.width)), + height: layout.round(layout.toDevicePixels(nativeSize.height)), + }; } [whiteSpaceProperty.setNative](value: CoreTypes.WhiteSpaceType) { diff --git a/packages/core/utils/layout-helper/index.ios.ts b/packages/core/utils/layout-helper/index.ios.ts index ec12062824..5c8826bcc9 100644 --- a/packages/core/utils/layout-helper/index.ios.ts +++ b/packages/core/utils/layout-helper/index.ios.ts @@ -69,10 +69,10 @@ export namespace layout { height: heightMode === 0 /* layout.UNSPECIFIED */ ? Number.POSITIVE_INFINITY : toDeviceIndependentPixels(height), }); - nativeSize.width = round(toDevicePixels(nativeSize.width)); - nativeSize.height = round(toDevicePixels(nativeSize.height)); - - return nativeSize; + return { + width: round(toDevicePixels(nativeSize.width)), + height: round(toDevicePixels(nativeSize.height)), + }; } export function measureSpecToString(measureSpec: number) {