Add a utility to lay out actors in a grid (#492)

* Add grid layout util

* Add docstrings, re-add cell alignment

* Fix cell alignment

* Port over tests to new layout API

* Fix comments

* Add design file, layout animations

* Feedback
This commit is contained in:
Steven Vergenz 2020-02-28 13:15:16 -08:00 коммит произвёл GitHub
Родитель e4cd2fb786
Коммит 8ce85cf046
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 550 добавлений и 97 удалений

133
design/utils.layout.grid.md Normal file
Просмотреть файл

@ -0,0 +1,133 @@
Grid Layout
============
It is often useful to lay out arrays of objects in a grid pattern: UI elements mostly. Because computing the position
of each object in that grid is cumbersome and non-trivial, especially when the contents of the grid change, I propose
adding a utility class that does it for you. `PlanarGridLayout` is the simplest and the first, but this proposal
allows additional layouts to be added later, such as `CurvedGridLayout` or `SphericalGridLayout`
`PlanarGridLayout`'s job is to place each actor in a cell at a given row/column, and guarantee that it has enough
space to not intersect its neighbors. Each cell is given a required width and height value, and the height of the row
and width of the column are guaranteed to be no less than this.
This implementation also supports grid and cell alignment. Each cell, and the grid as a whole, is allocated a box
of a given width and height, and the alignment determines where the content (the actor transform) is placed within
that box. So if you want the grid contents to flow downward from the grid anchor, you'd set `gridAlignment` to
`BottomCenter`. For cells, you generally want to use `MiddleCenter` alignment (the default), but if you have an actor
whose content is not centered on its transform, you might want to align it to an edge instead. The typical example
of this is an actor with text, since text can have its own alignment (called "anchor location").
I would also like to change how text is aligned to conform with this new more intuitive model,
but that's for another proposal.
API Design
-------------
```ts
/**
* Lay out actors in a grid along the root actor's local XY plane. Assign actors to the grid with [[addCell]],
* and apply updates with [[applyLayout]].
*/
class PlanarGridLayout {
/* PROPERTIES - the standard state getters */
public columnCount() {}
public rowCount() {}
public gridWidth() {}
public gridHeight() {}
public columnWidth(i: number) {}
public rowHeight(i: number) {}
public columnWidths() {}
public rowHeights() {}
/* METHODS */
/**
* Initialize a new grid layout.
* @param anchor The grid's anchor actor, the point to which the grid is aligned.
* @param gridAlignment How the grid should be aligned to its anchor, where [[BoxAlignment.TopLeft]] will place
* the grid above and to the left of the anchor, and the lower right corner will touch the anchor.
* @param defaultCellAlignment How cells should be aligned by default.
*/
public constructor(
private anchor: Actor,
public gridAlignment = BoxAlignment.MiddleCenter,
public defaultCellAlignment = BoxAlignment.MiddleCenter
) { }
/**
* Add an actor to the grid. The actor's position will not be updated until [[applyLayout]] is called.
* @param options The cell's configuration.
*/
public addCell(options: AddCellOptions) { }
/**
* Recompute the positions of all actors in the grid. Only modifies local position x and y for each actor.
* @param animateDuration How long it should take the actors to align to the grid. Defaults to instantly.
* @param animateCurve The actors' velocity curve.
*/
public applyLayout(animateDuration = 0, animateCurve = AnimationEaseCurve.EaseOutQuadratic) { }
}
/** Options for [[GridLayout.addCell]]. */
interface AddCellOptions {
/** The actor to be placed in the grid cell. Must be parented to the grid root. */
contents: Actor;
/** The row index, with 0 at the top. */
row: number;
/** The column index, with 0 on the left. */
column: number;
/** The width of this cell for layout purposes. Should include any desired padding. */
width: number;
/** The height of this cell for layout purposes. Should include any desired padding. */
height: number;
/** Where the actor should be placed within the cell box. Defaults to [[GridLayout.defaultCellAlignment]]. */
alignment?: BoxAlignment;
}
/** Basically just a rename of TextAnchorLocation, but not text specific */
enum BoxAlignment { TopLeft, ..., BottomRight }
```
Network Messaging
-------------------
No change.
Synchronization Concerns
----------------------------
No change.
Unity Concerns
-------------------
No change.
Examples
----------------
```ts
const grid = new MRE.PlanarGridLayout(root, MRE.BoxAlignment.BottomCenter);
const spacing = 2 / (25 - 1);
for (let i = 0; i < count; i++) {
grid.addCell({
row: Math.floor(i / 25),
column: i % 25,
width: spacing,
height: spacing,
contents: MRE.Actor.Create(this.app.context, { actor: {
name: 'ball',
parentId: root.id,
appearance: { meshId: ball.id }
}})
});
}
grid.applyLayout();
```

Просмотреть файл

@ -34,7 +34,8 @@ export default class ActorSpamTest extends Test {
text: {
contents: '25 actors',
height: 0.1,
anchor: MRE.TextAnchorLocation.BottomCenter
anchor: MRE.TextAnchorLocation.BottomCenter,
color: MRE.Color3.Teal()
}
}
});
@ -57,7 +58,8 @@ export default class ActorSpamTest extends Test {
text: {
contents: '50 actors',
height: 0.1,
anchor: MRE.TextAnchorLocation.BottomCenter
anchor: MRE.TextAnchorLocation.BottomCenter,
color: MRE.Color3.Teal()
}
}
});
@ -80,7 +82,8 @@ export default class ActorSpamTest extends Test {
text: {
contents: '100 actors',
height: 0.1,
anchor: MRE.TextAnchorLocation.BottomCenter
anchor: MRE.TextAnchorLocation.BottomCenter,
color: MRE.Color3.Teal()
}
}
});
@ -103,7 +106,8 @@ export default class ActorSpamTest extends Test {
text: {
contents: '200 actors',
height: 0.1,
anchor: MRE.TextAnchorLocation.BottomCenter
anchor: MRE.TextAnchorLocation.BottomCenter,
color: MRE.Color3.Teal()
}
}
});
@ -128,24 +132,22 @@ export default class ActorSpamTest extends Test {
transform: { local: { position: { y: 1, z: -1 } } }
}
});
const grid = new MRE.PlanarGridLayout(this.spamRoot, MRE.BoxAlignment.BottomCenter);
const spacing = 2 / (25 - 1);
for (let i = 0; i < count; i++) {
MRE.Actor.Create(this.app.context, {
actor: {
grid.addCell({
row: Math.floor(i / 25),
column: i % 25,
width: spacing,
height: spacing,
contents: MRE.Actor.Create(this.app.context, { actor: {
name: 'ball',
parentId: this.spamRoot.id,
appearance: { meshId: ball.id },
transform: {
local: {
position: {
x: -1 + spacing * (i % 25),
y: 0 - spacing * Math.floor(i / 25)
}
}
}
}
appearance: { meshId: ball.id }
}})
});
}
grid.applyLayout();
}
}

Просмотреть файл

@ -6,7 +6,6 @@
import * as MRE from '@microsoft/mixed-reality-extension-sdk';
import { Test } from '../test';
import { TableLayout } from '../utils/tableLayout';
interface ControlDefinition {
label: string;
@ -95,13 +94,18 @@ export default class AnimationTest extends Test {
private createControls(controls: ControlDefinition[], parent: MRE.Actor) {
const arrowMesh = this.assets.createCylinderMesh('arrow', 0.01, 0.08, 'z', 3);
const layout = new TableLayout(controls.length, 3, 0.25, 0.3);
const layout = new MRE.PlanarGridLayout(parent);
let i = 0;
const realtimeLabels = [] as ControlDefinition[];
for (const controlDef of controls) {
const label = controlDef.labelActor = layout.setCellContents(i, 1, MRE.Actor.Create(this.app.context, {
actor: {
let label: MRE.Actor, more: MRE.Actor, less: MRE.Actor;
layout.addCell({
row: i,
column: 1,
width: 0.3,
height: 0.25,
contents: label = MRE.Actor.Create(this.app.context, { actor: {
name: `${controlDef.label}-label`,
parentId: parent.id,
text: {
@ -111,28 +115,39 @@ export default class AnimationTest extends Test {
justify: MRE.TextJustify.Center,
color: MRE.Color3.FromInts(255, 200, 255)
}
}
}));
}})
});
controlDef.labelActor = label;
const less = layout.setCellContents(i, 0, MRE.Actor.Create(this.app.context, {
actor: {
layout.addCell({
row: i,
column: 0,
width: 0.3,
height: 0.25,
contents: less = MRE.Actor.Create(this.app.context, { actor: {
name: `${controlDef.label}-less`,
parentId: parent.id,
appearance: { meshId: arrowMesh.id },
collider: { geometry: { shape: MRE.ColliderType.Auto } },
transform: { local: { rotation: MRE.Quaternion.FromEulerAngles(0, 0, Math.PI * 1.5) } }
}
}));
}})
});
const more = layout.setCellContents(i, 2, MRE.Actor.Create(this.app.context, {
actor: {
name: `${controlDef.label}-more`,
parentId: parent.id,
appearance: { meshId: arrowMesh.id },
collider: { geometry: { shape: MRE.ColliderType.Auto } },
transform: { local: { rotation: MRE.Quaternion.FromEulerAngles(0, 0, Math.PI * 0.5) } }
}
}));
layout.addCell({
row: i,
column: 2,
width: 0.3,
height: 0.25,
contents: more = MRE.Actor.Create(this.app.context, {
actor: {
name: `${controlDef.label}-more`,
parentId: parent.id,
appearance: { meshId: arrowMesh.id },
collider: { geometry: { shape: MRE.ColliderType.Auto } },
transform: { local: { rotation: MRE.Quaternion.FromEulerAngles(0, 0, Math.PI * 0.5) } }
}
})
});
if (controlDef.realtime) { realtimeLabels.push(controlDef) }
@ -151,6 +166,7 @@ export default class AnimationTest extends Test {
i++;
}
layout.applyLayout();
setInterval(() => {
for (const rt of realtimeLabels) {

Просмотреть файл

@ -7,12 +7,11 @@ import * as MRE from '@microsoft/mixed-reality-extension-sdk';
import { Test } from '../test';
export default class AssetEarlyAssignmentTest extends Test {
public expectedResultDescription = "Assign asset properties before initialization is finished";
public expectedResultDescription = "Colored & textured sphere";
private assets: MRE.AssetContainer;
public async run(root: MRE.Actor): Promise<boolean> {
this.assets = new MRE.AssetContainer(this.app.context);
this.app.setOverrideText("Colored & textured sphere");
const tex = this.assets.createTexture('uvgrid', {
uri: `${this.baseUrl}/uv-grid.png`

Просмотреть файл

@ -7,7 +7,6 @@ import * as MRE from '@microsoft/mixed-reality-extension-sdk';
import { Test } from '../test';
import { LeftRightSwing } from '../utils/animations';
import { TableLayout } from '../utils/tableLayout';
export default class CollisionLayerTest extends Test {
public expectedResultDescription = "Observe different collision layer interactions";
@ -22,7 +21,7 @@ export default class CollisionLayerTest extends Test {
this.assets = new MRE.AssetContainer(this.app.context);
const layers = Object.values(MRE.CollisionLayer);
const tableLayout = new TableLayout(5, 5, 0.2, 0.5);
const layout = new MRE.PlanarGridLayout(root);
MRE.Actor.Create(this.app.context, {
actor: {
@ -36,47 +35,61 @@ export default class CollisionLayerTest extends Test {
}
});
// place column headers
// loop over each collision layer value
for (let i = 0; i < layers.length; i++) {
tableLayout.setCellContents(0, 1 + i, MRE.Actor.Create(this.app.context, {
actor: {
// create column headers
layout.addCell({
row: 0,
column: 1 + i,
width: 0.5,
height: 0.2,
contents: MRE.Actor.Create(this.app.context, { actor: {
name: `${layers[i]}ColLabel`,
parentId: root.id,
text: {
contents: layers[i],
height: 0.1,
anchor: MRE.TextAnchorLocation.MiddleCenter
anchor: MRE.TextAnchorLocation.MiddleCenter,
color: MRE.Color3.Teal()
}
}
}));
}
}})
});
// loop over each collision layer value
for (let i = 0; i < layers.length; i++) {
// create label
tableLayout.setCellContents(1 + i, 0, MRE.Actor.Create(this.app.context, {
actor: {
// create row headers
layout.addCell({
row: 1 + i,
column: 0,
width: 0.5,
height: 0.2,
contents: MRE.Actor.Create(this.app.context, { actor: {
name: `${layers[i]}RowLabel`,
parentId: root.id,
text: {
contents: layers[i],
height: 0.1,
anchor: MRE.TextAnchorLocation.MiddleCenter,
height: 0.1
color: MRE.Color3.Teal()
}
}
}));
}})
});
// loop over each type of collider that could hit the first
for (let j = 0; j < layers.length; j++) {
const widgetRoot = tableLayout.setCellContents(1 + i, 1 + j, MRE.Actor.Create(this.app.context, {
actor: {
let widgetRoot: MRE.Actor;
layout.addCell({
row: 1 + i,
column: 1 + j,
width: 0.5,
height: 0.2,
contents: widgetRoot = MRE.Actor.Create(this.app.context, { actor: {
name: `${layers[i]}/${layers[j]}`,
parentId: root.id
}
}));
}})
});
this.createWidget(widgetRoot, layers[i], layers[j]);
}
}
layout.applyLayout();
await this.stoppedAsync();
return true;

Просмотреть файл

@ -0,0 +1,94 @@
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as MRE from '@microsoft/mixed-reality-extension-sdk';
import { Test } from '../test';
export default class GridTest extends Test {
public expectedResultDescription = "Click balls to realign grid";
private assets: MRE.AssetContainer;
public async run(root: MRE.Actor): Promise<boolean> {
this.assets = new MRE.AssetContainer(this.app.context);
const box = this.assets.createBoxMesh('box', 0.24, 0.24, 0.24);
const ball = this.assets.createSphereMesh('ball', 0.15);
const anchor = MRE.Actor.Create(this.app.context, { actor: {
name: "anchor",
parentId: root.id,
transform: { local: { position: new MRE.Vector3(0, 1, -1)}},
appearance: { meshId: box.id }
}});
// create button grid
const buttonGrid = new MRE.PlanarGridLayout(anchor);
for (let i = 0; i < 9; i++) {
const alignment = Object.values(MRE.BoxAlignment)[i];
const button = MRE.Actor.Create(this.app.context, { actor: {
name: alignment + "-button",
parentId: anchor.id,
appearance: { meshId: ball.id },
collider: { geometry: { shape: MRE.ColliderType.Auto } }
}});
button.setBehavior(MRE.ButtonBehavior).onClick(() => {
buttonGrid.gridAlignment = alignment;
buttonGrid.applyLayout(0.5);
});
buttonGrid.addCell({
row: Math.floor(i / 3),
column: i % 3,
width: 0.3,
height: 0.3,
contents: button
});
const label = MRE.Actor.Create(this.app.context, { actor: {
name: "label",
parentId: anchor.id,
transform: { local: { position: { z: -0.16 } } },
text: {
contents: GridTest.ShortName(alignment),
height: 0.05,
anchor: GridTest.BoxToTextAlignment(alignment),
color: MRE.Color3.Teal()
}
}});
buttonGrid.addCell({
row: Math.floor(i / 3),
column: i % 3,
width: 0.3,
height: 0.3,
alignment,
contents: label
});
}
buttonGrid.applyLayout();
await this.stoppedAsync();
return true;
}
public cleanup() {
this.assets.unload();
}
private static ShortName(align: MRE.BoxAlignment) {
return align.charAt(0) + /-(.)/u.exec(align)[1];
}
private static BoxToTextAlignment(boxAlign: MRE.BoxAlignment) {
switch (boxAlign) {
case MRE.BoxAlignment.TopLeft: return MRE.TextAnchorLocation.BottomRight;
case MRE.BoxAlignment.TopCenter: return MRE.TextAnchorLocation.BottomCenter;
case MRE.BoxAlignment.TopRight: return MRE.TextAnchorLocation.BottomLeft;
case MRE.BoxAlignment.MiddleLeft: return MRE.TextAnchorLocation.MiddleRight;
case MRE.BoxAlignment.MiddleCenter: return MRE.TextAnchorLocation.MiddleCenter;
case MRE.BoxAlignment.MiddleRight: return MRE.TextAnchorLocation.MiddleLeft;
case MRE.BoxAlignment.BottomLeft: return MRE.TextAnchorLocation.TopRight;
case MRE.BoxAlignment.BottomCenter: return MRE.TextAnchorLocation.TopCenter;
case MRE.BoxAlignment.BottomRight: return MRE.TextAnchorLocation.TopLeft;
}
}
}

Просмотреть файл

@ -19,6 +19,7 @@ import GltfActorSyncTest from './gltf-actor-sync-test';
import GltfConcurrencyTest from './gltf-concurrency-test';
import GltfGenTest from './gltf-gen-test';
import GrabTest from './grab-test';
import GridTest from './grid-test';
import InputTest from './input-test';
import InterpolationTest from './interpolation-test';
import LibraryFailTest from './library-fail-test';
@ -59,6 +60,7 @@ export const Factories = {
'gltf-concurrency-test': (...args) => new GltfConcurrencyTest(...args),
'gltf-gen-test': (...args) => new GltfGenTest(...args),
'grab-test': (...args) => new GrabTest(...args),
'grid-test': (...args) => new GridTest(...args),
'input-test': (...args) => new InputTest(...args),
'interpolation-test': (...args) => new InterpolationTest(...args),
'library-fail-test': (...args) => new LibraryFailTest(...args),

Просмотреть файл

@ -1,39 +0,0 @@
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as MRE from '@microsoft/mixed-reality-extension-sdk';
const sumFn = (sum: number, x: number) => sum + x;
export class TableLayout {
private rowHeights: number[];
private columnWidths: number[];
private cells: MRE.Actor[][];
public get rowCount() { return this.rowHeights.length; }
public get columnCount() { return this.columnWidths.length; }
public get totalWidth() { return this.columnWidths.reduce(sumFn, 0); }
public get totalHeight() { return this.rowHeights.reduce(sumFn, 0); }
public constructor(rowCount: number, columnCount: number, rowHeight = 0.1, columnWidth = 0.5) {
this.rowHeights = new Array(rowCount).fill(rowHeight);
this.columnWidths = new Array(columnCount).fill(columnWidth);
this.cells = [];
for (let i = 0; i < rowCount; i++) {
this.cells.push(new Array(columnCount));
}
}
public setCellContents(row: number, column: number, actor: MRE.Actor) {
this.cells[row][column] = actor;
actor.transform.local.position.set(
-this.totalWidth / 2 + (
this.columnWidths.slice(0, column).reduce(sumFn, 0) + this.columnWidths[column] / 2),
this.totalHeight / 2 - (
this.rowHeights.slice(0, row).reduce(sumFn, 0) + this.rowHeights[row] / 2),
0
);
return actor;
}
}

Просмотреть файл

@ -0,0 +1,41 @@
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
/** Describes a relative position in a [[GridLayout]]. */
export enum BoxAlignment {
/** Position above and to the left of the anchor. */
TopLeft = 'top-left',
/** Position directly above the anchor. */
TopCenter = 'top-center',
/** Position above and to the right of the anchor. */
TopRight = 'top-right',
/** Position directly left of the anchor. */
MiddleLeft = 'middle-left',
/** Position directly on top of the anchor. */
MiddleCenter = 'middle-center',
/** Position directly right of the anchor. */
MiddleRight = 'middle-right',
/** Position below and to the left of the anchor. */
BottomLeft = 'bottom-left',
/** Position directly below the anchor. */
BottomCenter = 'bottom-center',
/** Position below and to the right of the anchor. */
BottomRight = 'bottom-right',
}
/** Invert a [[BoxAlignment]] value around the anchor (e.g. TopLeft to BottomRight). */
export function InvertBoxAlignment(align: BoxAlignment) {
switch (align) {
case BoxAlignment.TopLeft: return BoxAlignment.BottomRight;
case BoxAlignment.TopCenter: return BoxAlignment.BottomCenter;
case BoxAlignment.TopRight: return BoxAlignment.BottomLeft;
case BoxAlignment.MiddleLeft: return BoxAlignment.MiddleRight;
case BoxAlignment.MiddleCenter: return BoxAlignment.MiddleCenter;
case BoxAlignment.MiddleRight: return BoxAlignment.MiddleLeft;
case BoxAlignment.BottomLeft: return BoxAlignment.TopRight;
case BoxAlignment.BottomCenter: return BoxAlignment.TopCenter;
case BoxAlignment.BottomRight: return BoxAlignment.TopLeft;
}
}

Просмотреть файл

@ -3,7 +3,9 @@
* Licensed under the MIT License.
*/
export * from './boxAlignment';
export * from './guid';
export * from './log';
export * from './planarGridLayout';
export * from './readonlyMap';
export * from './webHost';

Просмотреть файл

@ -0,0 +1,190 @@
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { Actor, AnimationEaseCurves, BoxAlignment, InvertBoxAlignment, Vector3 } from '..';
/** Options for [[GridLayout.addCell]]. */
export interface AddCellOptions {
/** The actor to be placed in the grid cell. Must be parented to the grid root. */
contents: Actor;
/** The row index, with 0 at the top. */
row: number;
/** The column index, with 0 on the left. */
column: number;
/** The width of this cell for layout purposes. Should include any desired padding. */
width: number;
/** The height of this cell for layout purposes. Should include any desired padding. */
height: number;
/** Where the actor should be placed within the cell box. Defaults to [[GridLayout.defaultCellAlignment]]. */
alignment?: BoxAlignment;
}
const sumFn = (sum: number, x: number) => sum + x;
const maxFn = (max: number, x: number) => Math.max(max, x);
/**
* Lay out actors in a grid along the root actor's local XY plane. Assign actors to the grid with [[addCell]],
* and apply updates with [[applyLayout]].
*/
export class PlanarGridLayout {
private contents: AddCellOptions[] = [];
/**
* Initialize a new grid layout.
* @param anchor The grid's anchor actor, the point to which the grid is aligned.
* @param gridAlignment How the grid should be aligned to its anchor, where [[BoxAlignment.TopLeft]] will place
* the grid above and to the left of the anchor, and the lower right corner will touch the anchor.
* @param defaultCellAlignment How cells should be aligned by default.
*/
public constructor(
private anchor: Actor,
public gridAlignment = BoxAlignment.MiddleCenter,
public defaultCellAlignment = BoxAlignment.MiddleCenter
) { }
/** The number of columns in this grid. */
public getColumnCount() {
return this.contents.map(c => c.column).reduce(maxFn, -1) + 1;
}
/** The number of rows in this grid. */
public getRowCount() {
return this.contents.map(c => c.row).reduce(maxFn, -1) + 1;
}
/** The width of the full grid. */
public getGridWidth() {
const colCount = this.getColumnCount();
let width = 0;
for (let i = 0; i < colCount; i++) {
width += this.getColumnWidth(i);
}
return width;
}
/** The height of the full grid. */
public getGridHeight() {
const rowCount = this.getRowCount();
let height = 0;
for (let i = 0; i < rowCount; i++) {
height += this.getRowHeight(i);
}
return height;
}
/**
* The width of a particular column.
* @param i The column index.
*/
public getColumnWidth(i: number) {
return this.contents.filter(c => c.column === i).map(c => c.width).reduce(maxFn, 0);
}
/**
* The height of a particular row.
* @param i The row index.
*/
public getRowHeight(i: number) {
return this.contents.filter(c => c.row === i).map(c => c.height).reduce(maxFn, 0);
}
/** The widths of every column. */
public getColumnWidths() {
return this.contents.reduce((arr, c) => {
arr[c.column] = Math.max(arr[c.column] ?? 0, c.width);
return arr;
}, [] as number[]);
}
/** The heights of every row. */
public getRowHeights() {
return this.contents.reduce((arr, c) => {
arr[c.row] = Math.max(arr[c.row] ?? 0, c.height);
return arr;
}, [] as number[]);
}
/**
* Add an actor to the grid. The actor's position will not be updated until [[applyLayout]] is called.
* @param options The cell's configuration.
*/
public addCell(options: AddCellOptions) {
const { contents } = options;
if (contents.parent !== this.anchor) {
throw new Error("Grid cell contents must be parented to the grid root");
}
// insert cell
this.contents.push(options);
}
/** Recompute the positions of all actors in the grid. */
public applyLayout(animateDuration = 0, animateCurve = AnimationEaseCurves.EaseOutQuadratic) {
const colWidths = this.getColumnWidths();
const rowHeights = this.getRowHeights();
const gridAlign = PlanarGridLayout.getOffsetFromAlignment(
InvertBoxAlignment(this.gridAlignment),
colWidths.reduce(sumFn, 0),
rowHeights.reduce(sumFn, 0))
.negate();
for (const cell of this.contents) {
const cellPosition = new Vector3(
colWidths.slice(0, cell.column).reduce(sumFn, 0),
-rowHeights.slice(0, cell.row).reduce(sumFn, 0),
cell.contents.transform.local.position.z);
const cellAlign = PlanarGridLayout.getOffsetFromAlignment(
cell.alignment ?? this.defaultCellAlignment,
colWidths[cell.column], rowHeights[cell.row]
);
const destination = gridAlign.add(cellPosition).add(cellAlign);
if (animateDuration > 0) {
cell.contents.animateTo(
{ transform: { local: { position: destination } } },
animateDuration, animateCurve);
} else {
cell.contents.transform.local.position = destination;
}
}
}
private static getOffsetFromAlignment(anchor: BoxAlignment, width: number, height: number) {
const offset = new Vector3();
// set horizontal alignment
switch(anchor) {
case BoxAlignment.TopRight:
case BoxAlignment.MiddleRight:
case BoxAlignment.BottomRight:
offset.x = 1;
break;
case BoxAlignment.TopCenter:
case BoxAlignment.MiddleCenter:
case BoxAlignment.BottomCenter:
offset.x = 0.5;
break;
default:
offset.x = 0;
}
// set vertical alignment
switch (anchor) {
case BoxAlignment.BottomLeft:
case BoxAlignment.BottomCenter:
case BoxAlignment.BottomRight:
offset.y = -1;
break;
case BoxAlignment.MiddleLeft:
case BoxAlignment.MiddleCenter:
case BoxAlignment.MiddleRight:
offset.y = -0.5;
break;
default:
offset.y = 0;
}
return offset.multiplyByFloats(width, height, 1);
}
}