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:
Родитель
e4cd2fb786
Коммит
8ce85cf046
|
@ -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);
|
||||
}
|
||||
}
|
Загрузка…
Ссылка в новой задаче