diff --git a/.flowconfig b/.flowconfig index 7d09d20..e76175c 100644 --- a/.flowconfig +++ b/.flowconfig @@ -14,6 +14,7 @@ .*/node_modules/npmconf/.* .*/node_modules/radium/modules/.* .*/node_modules/react-hot-loader/.* +.*/node_modules/react-motion/src/.* .*/node_modules/rimraf/.* .*/node_modules/watchify/.* .*/node_modules/webpack-dev-server/.* diff --git a/README.md b/README.md index d6d04ce..221efbf 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ class Rectangle extends Component { } } -export default Subview(Rectangle); +export default Subview()(Rectangle); ``` Here's how to use `AutoDOM` components: @@ -143,6 +143,59 @@ import ConstraintLayout, { Superview, AutoDOM } from "radium-constraints"; When using `AutoSVG` components, make sure to pass "g" instead of "div" to the ``'s `container` prop. +## Animation +You can add automatic layout animation to any `Subview` or `AutoSVG`/`AutoDOM` components! The animation system works with both `` from `react-motion` and `` from `victory-core`. To create Victory-animated versions of `AutoDOM` components, for example, you'd do the following: + +```es6 +import { animateDOM } from "radium-constraints"; +import { VictoryAnimation } from "victory-core"; + +const VictoryAnimationAutoDOM = animateDOM({ + animatorClass: VictoryAnimation, + animatorProps: (layout) => ({ + data: { + width: layout.width, + height: layout.height, + top: layout.top, + right: layout.right, + bottom: layout.bottom, + left: layout.left + } + }) +}); + +// Later, in render() + + This is a subview animated by VictoryAnimation!!!!!! + +``` + +When different constraints enter either the top-level or component-level `constraints` prop, the new animated component automatically tweens between the previous and newly calculated layout, diffing/removing/adding constraints behind the scenes. + +If you're using the `Subview` higher-order component, you can pass an object with `animatorClass` and `animatorProps` to the first curried argument of `Subview` like so: + +```es6 +export default Subview({ + animatorClass: VictoryAnimation, + animatorProps: (layout) => ({ + data: { + width: layout.width, + height: layout.height, + top: layout.top, + right: layout.right, + bottom: layout.bottom, + left: layout.left + } + }) +})(SomeCustomComponent); +``` + ## Demo There are more complex examples on the demo page. Check out the code in [app.jsx](https://github.com/FormidableLabs/radium-constraints/blob/master/demo/app.jsx). @@ -164,14 +217,13 @@ React Constraints uses an asynchronous layout engine running on a pool of WebWor Resolving and incrementally adding/removing constraints are cheap enough to run in 60fps for most cases. However, the initial layout calculations on first load are the most expensive, and you may notice a slight delay in layout (although this does not block the main thread). We're working on a build tool that will pre-calculate initial layouts and feed them into your components to prevent this. ## Browser support -This library's browser support aligns with React's browser support minus IE 8 and 9 (neither support Web Workers.) The library requires no polyfills for its supported environments. +This library's browser support aligns with React's browser support minus IE 8 and 9 (neither support Web Workers). The library requires a Promise polyfill for non-ES6 environments. ## Roadmap In order of priority: -- Remove dependency on autolayout.js in favor of a simple wrapper around the Kiwi constraint solver. -- Support SVG `path` elements in AutoSVG. - Create build tool to pre-calculate initial layouts. -- Decide on an animation strategy (requires support for removing constraints). +- Support SVG `path` elements in AutoSVG. +- Remove dependency on autolayout.js in favor of a simple wrapper around the Kiwi constraint solver. - Allow for self-referential subviews in the constraint props array without using the subview string. ## Constraint Builder API diff --git a/demo/app.jsx b/demo/app.jsx index 9abcd16..884d195 100644 --- a/demo/app.jsx +++ b/demo/app.jsx @@ -1,14 +1,54 @@ // @flow /* eslint-env browser */ /* eslint-disable new-cap,no-magic-numbers */ +import type ConstraintBuilder from "../src/constraint-builder"; import React, { Component } from "react"; import ReactDOM from "react-dom"; -import ConstraintLayout, { Superview, AutoDOM, constrain } from "../src/index.js"; +import ConstraintLayout, { + Superview, + AutoDOM, + animateDOM, + constrain +} from "../src"; + +// Animators +import { Motion, spring, presets } from "react-motion"; +import { VictoryAnimation } from "victory-core"; type State = { windowWidth: ?number, - windowHeight: ?number -} + windowHeight: ?number, + dynamicConstraints: Array, + activeConstraints: number +}; + +const MotionAutoDOM = animateDOM({ + animatorClass: Motion, + animatorProps: (layout) => ({ + style: { + width: spring(layout.width, presets.wobbly), + height: spring(layout.height, presets.wobbly), + top: spring(layout.top, presets.wobbly), + right: spring(layout.right, presets.wobbly), + bottom: spring(layout.bottom, presets.wobbly), + left: spring(layout.left, presets.wobbly) + } + }) +}); + +const VictoryAnimationAutoDOM = animateDOM({ + animatorClass: VictoryAnimation, + animatorProps: (layout) => ({ + data: { + width: layout.width, + height: layout.height, + top: layout.top, + right: layout.right, + bottom: layout.bottom, + left: layout.left + } + }) +}); const colors = { formidared: "#FF4136", @@ -31,6 +71,49 @@ const styles = { } }; +const dynamicConstraintsQueue = [ + [ + constrain.subview("react-motion-note").centerX + .to.equal.superview.centerX.times(0.5), + constrain.subview("react-motion-note").centerY + .to.equal.superview.centerY, + constrain.subview("victory-animation-note").centerX + .to.equal.superview.centerX.times(1.5), + constrain.subview("victory-animation-note").centerY + .to.equal.superview.centerY + ], + [ + constrain.subview("react-motion-note").centerX + .to.equal.superview.centerX, + constrain.subview("react-motion-note").centerY + .to.equal.superview.centerY.times(0.5), + constrain.subview("victory-animation-note").centerX + .to.equal.superview.centerX, + constrain.subview("victory-animation-note").centerY + .to.equal.superview.centerY.times(1.5) + ], + [ + constrain.subview("react-motion-note").centerX + .to.equal.superview.centerX.times(1.5), + constrain.subview("react-motion-note").centerY + .to.equal.superview.centerY, + constrain.subview("victory-animation-note").centerX + .to.equal.superview.centerX.times(0.5), + constrain.subview("victory-animation-note").centerY + .to.equal.superview.centerY + ], + [ + constrain.subview("react-motion-note").centerX + .to.equal.superview.centerX, + constrain.subview("react-motion-note").centerY + .to.equal.superview.centerY.times(1.5), + constrain.subview("victory-animation-note").centerX + .to.equal.superview.centerX, + constrain.subview("victory-animation-note").centerY + .to.equal.superview.centerY.times(0.5) + ] +]; + class App extends Component { state: State; @@ -38,7 +121,9 @@ class App extends Component { super(props); this.state = { windowWidth: window.innerWidth, - windowHeight: window.innerHeight + windowHeight: window.innerHeight, + activeConstraints: 0, + dynamicConstraints: dynamicConstraintsQueue[0] }; } @@ -57,6 +142,22 @@ class App extends Component { }); } ); + + setInterval(() => { + const nextActive = + this.state.activeConstraints !== dynamicConstraintsQueue.length - 1 + ? this.state.activeConstraints + 1 : 0; + + // console.log(nextActive); + + const nextConstraints = dynamicConstraintsQueue[nextActive]; + + // console.log(nextConstraints); + this.setState({ // eslint-disable-line react/no-did-mount-set-state + activeConstraints: nextActive, + dynamicConstraints: nextConstraints + }); + }, 2000); } render() { @@ -70,19 +171,24 @@ class App extends Component { style={{ background: colors.shade1 }} - constraints={[ - constrain.subview("note").centerX.to.equal.superview.centerX, - constrain.subview("note").centerY.to.equal.superview.centerY - ]} + constraints={this.state.dynamicConstraints} > - - Worst clock ever! Resize the window for full effect. - + This is a subview animated by React Motion!!!!!!!!!! + + + This is a subview animated by VictoryAnimation!!!!!! + - diff --git a/interfaces/autolayout.js b/interfaces/autolayout.js index 4b879a7..06b3edd 100644 --- a/interfaces/autolayout.js +++ b/interfaces/autolayout.js @@ -2,12 +2,12 @@ declare module "autolayout" { declare type Relation = | "equ" | "leq" - | "geq" + | "geq"; declare type Priority = | 1000 | 750 - | 250 + | 250; declare type Constraint = { view1?: ?string, @@ -63,12 +63,12 @@ declare module "autolayout/lib/kiwi/View" { declare type Relation = | "equ" | "leq" - | "geq" + | "geq"; declare type Priority = | 1000 | 750 - | 250 + | 250; declare type Constraint = { view1?: ?string, @@ -119,5 +119,5 @@ declare module "autolayout/lib/kiwi/View" { addConstraints(constraints: Array): View; } - declare function exports(): View + declare function exports(): View; } diff --git a/interfaces/react-motion.js b/interfaces/react-motion.js new file mode 100644 index 0000000..ffdfb6b --- /dev/null +++ b/interfaces/react-motion.js @@ -0,0 +1,5 @@ +declare module "react-motion" { + declare var Motion: ReactClass; + declare var spring: any; + declare var presets: any; +} diff --git a/interfaces/victory-core.js b/interfaces/victory-core.js new file mode 100644 index 0000000..38e9684 --- /dev/null +++ b/interfaces/victory-core.js @@ -0,0 +1,3 @@ +declare module "victory-core" { + declare var VictoryAnimation: ReactClass; +} diff --git a/interfaces/worker.js b/interfaces/worker.js index 1f6343b..52bbe4f 100644 --- a/interfaces/worker.js +++ b/interfaces/worker.js @@ -9,6 +9,7 @@ type WorkerEventHandler = (event: WorkerEvent) => mixed; declare class Worker { constructor(URL: ?string): void; onmessage: WorkerEventHandler; + onerror: WorkerEventHandler; postMessage(messsage: Cloneable): void; terminate(): void; } diff --git a/package.json b/package.json index 0d7e6bb..fdf0478 100644 --- a/package.json +++ b/package.json @@ -22,33 +22,38 @@ "build-lib": "builder run clean-lib && babel plugins/src -d plugins/lib --copy-files && babel src -d lib --copy-files" }, "dependencies": { - "autolayout": "FormidableLabs/autolayout.js#7a28278", - "babel-plugin-webpack-loaders": "^0.4.0", - "builder": "^2.8.0", - "builder-radium-component": "^2.0.0", + "autolayout": "FormidableLabs/autolayout.js#4f3bd46", + "babel-plugin-webpack-loaders": "^0.5.0", + "builder": "^2.10.1", + "builder-radium-component": "^2.1.2", "coveralls": "^2.11.8", - "react": "^0.14.7", - "react-dom": "^0.14.7" + "lodash.isequal": "^4.2.0" }, "devDependencies": { - "babel-eslint": "^6.0.0", - "babel-polyfill": "^6.6.1", - "babel-preset-es2015": "^6.6.0", - "babel-register": "^6.6.0", - "builder": "^2.8.0", - "builder-radium-component-dev": "^2.0.0", + "babel-eslint": "^6.0.4", + "babel-polyfill": "^6.9.1", + "babel-preset-es2015": "^6.9.0", + "babel-register": "^6.9.0", + "builder": "^2.10.1", + "builder-radium-component-dev": "^2.1.2", "chai": "^3.2.0", - "enzyme": "^2.1.0", - "eslint-plugin-flow-vars": "^0.2.1", + "enzyme": "^2.3.0", + "eslint-plugin-flow-vars": "^0.4.0", "exports-loader": "^0.6.3", - "flow-bin": "^0.22.1", - "mocha": "^2.3.3", - "react": "^0.14.6", - "react-addons-test-utils": "^0.14.7", - "react-dom": "^0.14.6", - "sinon": "^1.17.2", + "flow-bin": "^0.26.0", + "mocha": "^2.5.3", + "react": "^15.1.0", + "react-addons-test-utils": "^15.1.0", + "react-dom": "^15.1.0", + "react-motion": "^0.4.4", + "sinon": "^1.17.4", "sinon-chai": "^2.8.0", + "victory-core": "^3.0.0", "webpack-dev-server": "^1.14.1" }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.0-0", + "react-dom": "^0.14.0 || ^15.0.0-0" + }, "author": "Tyler Thompson" } diff --git a/src/autodom.js b/src/autodom.js index c89ea87..bff3c40 100644 --- a/src/autodom.js +++ b/src/autodom.js @@ -2,6 +2,11 @@ import { DOM as DOMFactories } from "react"; import Subview from "./subview"; +type AnimatedDOMArgs = { + animatorClass: ReactClass, + animatorProps: (layout: Layout) => Object, +}; + // This is to override any DOM nodes that can't // use the default position: absolute transform const transformers = {}; @@ -22,11 +27,25 @@ export const whitelist = [ "select", "textarea" ]; -export default Object.keys(DOMFactories) - .filter((key) => whitelist.indexOf(key) !== -1) - .reduce((acc, key) => { +const safeDOMFactories = Object.keys(DOMFactories) + .filter((key) => whitelist.indexOf(key) !== -1); + +export const AutoDOM = safeDOMFactories.reduce((acc, key) => { + acc[key] = transformers[key] + ? Subview({ + layoutTransformer: transformers[key] + })(key) + : Subview()(key); + return acc; +}, {}); + +export const animateDOM = (args: AnimatedDOMArgs) => + safeDOMFactories.reduce((acc, key) => { acc[key] = transformers[key] - ? Subview(key, transformers[key]) - : Subview(key); + ? Subview({ + ...args, + layoutTransformer: transformers[key] + })(key) + : Subview(args)(key); return acc; }, {}); diff --git a/src/autosvg.js b/src/autosvg.js index ec6fd0b..81f42ad 100644 --- a/src/autosvg.js +++ b/src/autosvg.js @@ -57,7 +57,18 @@ const transformers = { } }; -export default Object.keys(transformers).reduce((acc, key) => { - acc[key] = Subview(key, transformers[key]); +export const AutoSVG = Object.keys(transformers).reduce((acc, key) => { + acc[key] = Subview({ + layoutTransformer: transformers[key] + })(key); return acc; }, {}); + +export const animateSVG = (args: AnimatedDOMArgs) => + Object.keys(transformers).reduce((acc, key) => { + acc[key] = Subview({ + ...args, + layoutTransformer: transformers[key] + })(key); + return acc; + }, {}); diff --git a/src/constraint-builder.js b/src/constraint-builder.js index 06be90e..df74bcb 100644 --- a/src/constraint-builder.js +++ b/src/constraint-builder.js @@ -162,4 +162,4 @@ class ConstraintBuilder { } } -export default ((): ConstraintBuilder => new ConstraintBuilder())(); +export default new ConstraintBuilder(); diff --git a/src/engine.js b/src/engine.js index 54ed4f6..524cb95 100644 --- a/src/engine.js +++ b/src/engine.js @@ -1,5 +1,6 @@ // @flow /* eslint-env worker */ +/* eslint no-console: ["error", { allow: ["warn", "error"] }] */ import type { SubView } from "autolayout"; import type ConstraintBuilder from "./constraint-builder"; import View from "autolayout/lib/kiwi/View"; @@ -27,7 +28,48 @@ export type WorkerArgs = { intrinsicHeight: number }, constraints?: Array -} +}; + +type RegistrationArgs = { + viewName: string, + size?: { + width: number, + height: number + }, + spacing?: Array, +}; + +type SpacingArgs = { + viewName: string, + spacing: Array +}; + +type SizeArgs = { + viewName: string, + size: { + width: number, + height: number + } +}; + +type IntrinsicsArgs = { + viewName: string, + subviewName: string, + intrinsics: { + intrinsicWidth: number, + intrinsicHeight: number + } +}; + +type ConstraintsArgs = { + viewName: string, + constraints: Array +}; + +type InitializeArgs = { + viewName: string, + layoutProps: Object, +}; // Decorates engine methods to post a "callback" // message to the main thread with the method @@ -52,68 +94,58 @@ export default class Engine { this.views = {}; } - registerView(args: WorkerArgs): bool { - const { viewName } = args; + registerView({ viewName, size, spacing }: RegistrationArgs): bool { const view = new View(); this.views[viewName] = view; - const size = args.size || null; if (size) { view.setSize(size.width, size.height); } - if (args.spacing) { - view.setSpacing(args.spacing); + if (spacing) { + view.setSpacing(spacing); } return true; } - deregisterView(args: WorkerArgs): bool { - const { viewName } = args; + deregisterView({ viewName }: RegistrationArgs): bool { this.views[viewName] = null; return true; } - setSpacing(args: WorkerArgs): ?{ [key: string]: Layout } { - const { viewName } = args; + setSpacing({ viewName, spacing }: SpacingArgs): ?{ [key: string]: Layout } { const view = this.views[viewName]; if (!view) { - return null; - } - const spacing = args.spacing || null; - if (!spacing) { + console.warn("no view for name:", viewName); return null; } view.setSpacing(spacing); return this.subviews({ viewName }); } - setSize(args: WorkerArgs): ?{ [key: string]: Layout } { - const { viewName } = args; + setSize({ viewName, size }: SizeArgs): ?{ [key: string]: Layout } { const view = this.views[viewName]; - const size = args.size; - if (!view || !size) { + if (!view) { + console.warn("no view for name:", viewName); return null; } view.setSize(size.width, size.height); return this.subviews({ viewName }); } - addIntrinsics(args: WorkerArgs): ?{ [key: string]: Layout } { - const { viewName } = args; - const subviewName = args.subviewName || null; - if (!subviewName) { - return null; - } + addIntrinsics( + { viewName, subviewName, intrinsics }: IntrinsicsArgs + ): ?{ [key: string]: Layout } { const view = this.views[viewName] || null; if (!view) { + console.warn("no view for name:", viewName); return null; } - const subview = this.getSubview(view, subviewName); - const intrinsics = args.intrinsics || null; - if (!subview || !intrinsics) { + const subview = this.getSubview(view, subviewName); + if (!subview) { + console.warn("no subview for name:", subviewName); return null; } @@ -125,26 +157,45 @@ export default class Engine { return this.subviews({ viewName }); } - addConstraints(args: WorkerArgs): ?{ [key: string]: Layout } { - const { viewName } = args; + addConstraints( + { viewName, constraints }: ConstraintsArgs + ): ?{ [key: string]: Layout } { const view = this.views[viewName]; if (!view) { + console.warn("no view for name:", viewName); return null; } - const constraints = args.constraints || null; - if (!constraints) { + + if (!constraints.length) { + console.warn("empty constraints"); return null; } + view.addConstraints(constraints); return this.subviews({ viewName }); } - initializeSubviews(args: WorkerArgs): ?{ [key: string]: Layout } { - const { viewName } = args; - const layoutProps = args.layoutProps || null; - if (!layoutProps) { + removeConstraints( + { viewName, constraints }: ConstraintsArgs + ): ?{ [key: string]: Layout } { + const view = this.views[viewName]; + if (!view) { + console.warn("no view for name:", viewName); + return null; + } + + if (!constraints.length) { + console.warn("empty constraints"); return null; } + + view.removeConstraints(constraints); + return this.subviews({ viewName }); + } + + initializeSubviews( + { viewName, layoutProps }: InitializeArgs + ): ?{ [key: string]: Layout } { const constraints = layoutProps .map((element) => element.constraints) .filter((constraint) => constraint) @@ -169,9 +220,10 @@ export default class Engine { return this.subviews({ viewName }); } - subviews(args: WorkerArgs): ?{ [key: string]: Layout } { - const view = this.views[args.viewName]; + subviews({ viewName }: RegistrationArgs): ?{ [key: string]: Layout } { + const view = this.views[viewName]; if (!view || !view.subViews) { + console.warn("subviews() failed for:", viewName); return null; } diff --git a/src/index.js b/src/index.js index 35308fb..01fc011 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,17 @@ import ConstraintLayout from "./constraint-layout"; import Superview from "./superview"; import Subview from "./subview"; -import AutoDOM from "./autodom"; -import AutoSVG from "./autosvg"; +import { AutoDOM, animateDOM } from "./autodom"; +import { AutoSVG, animateSVG } from "./autosvg"; import constrain from "./constraint-builder"; export default ConstraintLayout; -export { Superview, Subview, AutoDOM, AutoSVG, constrain }; +export { + Superview, + Subview, + AutoDOM, + animateDOM, + AutoSVG, + animateSVG, + constrain +}; diff --git a/src/layout-client.js b/src/layout-client.js index 62705a2..6fb600d 100644 --- a/src/layout-client.js +++ b/src/layout-client.js @@ -5,6 +5,8 @@ import type { WorkerArgs } from "./engine"; import type { Proxyable } from "./worker-proxy"; import WorkerProxy from "./worker-proxy"; +const DEFAULT_THREAD_COUNT = 4; + const LayoutWorker = () => { return new Worker( URL.createObjectURL(new Blob( @@ -13,7 +15,6 @@ const LayoutWorker = () => { )) ); }; -const DEFAULT_THREAD_COUNT = 4; export default class LayoutClient { views: { [key: string]: ?{ workerId: number } }; @@ -48,18 +49,16 @@ export default class LayoutClient { size: { width: number, height: number }, spacing: Array, callback: () => void - ) { + ): Promise { if (this.views[viewName]) { - return; + return Promise.reject("View already exists!"); } this.views[viewName] = { workerId: this.currentWorker, callback }; - this.workers[this.currentWorker].run("registerView", { - viewName, size, spacing - }, callback); + const currentWorker = this.currentWorker; // Cycle through the worker pool if (this.currentWorker === this.workers.length - 1) { @@ -67,6 +66,12 @@ export default class LayoutClient { } else { this.currentWorker++; } + + return new Promise((resolve) => { + this.workers[currentWorker].run("registerView", { + viewName, size, spacing + }, resolve); + }); } deregisterView(viewName: string, callback: () => void) { @@ -88,15 +93,15 @@ export default class LayoutClient { return this.workers[this.views[viewName].workerId]; } - run( - method: string, - args: WorkerArgs, - callback: ((result: ?Cloneable) => void) - ) { + run(method: string, args: WorkerArgs): Promise { const worker = this.workerForView(args.viewName); - if (worker) { - worker.run(method, args, callback); - } + return new Promise((resolve, reject) => { + if (worker) { + worker.run(method, args, resolve); + } else { + reject("No worker found for this view. Did you register it?"); + } + }); } terminate() { diff --git a/src/subview.js b/src/subview.js index 63d5536..e4fbd2a 100644 --- a/src/subview.js +++ b/src/subview.js @@ -42,6 +42,7 @@ const transformer = ( // eslint-disable-line max-params ComposedComponent, newProps, props.children ); }; + // The default DOM layout map. // Absolutely positions with style attributes. const defaultLayoutTransformer = (layout: Layout): Object => { @@ -56,53 +57,74 @@ const defaultLayoutTransformer = (layout: Layout): Object => { }; }; -export default ( - ComposedComponent: ReactClass, - layoutTransformer: LayoutTransformer = // eslint-disable-line space-infix-ops - defaultLayoutTransformer -): ReactClass => -class extends Component { - props: Props; - - static contextTypes = { - subviews: PropTypes.object - }; +type SubviewArgs = { + animatorClass?: ReactClass, + animatorProps?: (layout: Layout) => Object, + layoutTransformer?: LayoutTransformer +}; - render(): ?Element { - const zeroLayout = { - width: 0, - height: 0, - top: 0, - right: 0, - bottom: 0, - left: 0 - }; +export default (args: SubviewArgs = {}): // eslint-disable-line space-infix-ops +(ComposedComponent: ReactClass) => ReactClass => { + const { + animatorClass: Animator, + animatorProps, + layoutTransformer + } = args; - const { name } = this.props; - const { subviews } = this.context; - const layout = subviews && - name && - subviews[name] && - subviews[name].layout - ? subviews[name].layout - : zeroLayout; - - // Is this an AutoDOM component? - if (typeof ComposedComponent === "string") { - return transformer( - ComposedComponent, - this.props, - layout, - layoutTransformer - ); - } + return (ComposedComponent: ReactClass): ReactClass => + class extends Component { + props: Props; - // If not, pass the layout props to the wrapped component - return ( - - ); - } + static contextTypes = { + subviews: PropTypes.object + }; + + createSubviewElement(layout: Layout) { + // Is this an AutoDOM component? + if (typeof ComposedComponent === "string") { + return transformer( + ComposedComponent, + this.props, + layout, + layoutTransformer || defaultLayoutTransformer + ); + } + + // If not, pass the layout props to the enhanced component + return ( + + ); + } + + render(): ?Element { + const zeroLayout = { + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0 + }; + + const { name } = this.props; + const { subviews } = this.context; + const layout = + subviews && + name && + subviews[name] && + subviews[name].layout || + zeroLayout; + + return Animator && animatorProps ? ( + + {(interpolatedLayout) => + this.createSubviewElement(interpolatedLayout) + } + + ) : this.createSubviewElement(layout); + } + }; }; diff --git a/src/superview.js b/src/superview.js index 44079bb..4edc2f2 100644 --- a/src/superview.js +++ b/src/superview.js @@ -6,6 +6,7 @@ import type LayoutClient from "./layout-client"; import type ConstraintBuilder from "./constraint-builder"; import { Component, PropTypes, createElement } from "react"; +import isEqual from "lodash.isequal"; import UUID from "./uuid"; import extractLayoutProps from "./extract-layout-props"; @@ -61,27 +62,55 @@ class Superview extends Component { constraints: constraints.map((c) => c.build()) } : []); - client.registerView(name, size, spacing, () => { - client.run("initializeSubviews", { + client.registerView(name, size, spacing) + .then(() => client.run("initializeSubviews", { viewName: name, layoutProps - }, (layout) => this.onLayout(layout)); - }); + })) + .then((layout) => this.onLayout(layout)); } componentWillReceiveProps(nextProps: Props) { const { name: viewName, width, height } = nextProps; const { width: oldWidth, height: oldHeight } = this.props; - if (width === oldWidth && height === oldHeight) { + + const sameSize = width === oldWidth && height === oldHeight; + const sameConstraints = isEqual( + this.props.constraints, + nextProps.constraints + ); + + if (sameSize && sameConstraints) { return; } - this.context.client.run("setSize", { + const onlyHasNewSize = !sameSize && sameConstraints; + + const resizePromise = this.context.client.run("setSize", { viewName, size: { width, height } - }, (layout) => this.onLayout(layout)); + }); + + const reconstrainPromise = this.context.client.run("removeConstraints", { + viewName, + constraints: this.props.constraints && + this.props.constraints.map((c) => c.build()) || [] + }) + .then(() => this.context.client.run("addConstraints", { + viewName, + constraints: nextProps.constraints && + nextProps.constraints.map((c) => c.build()) || [] + })) + .then(() => resizePromise); + + const layoutPromise = onlyHasNewSize + ? resizePromise + : reconstrainPromise; + + layoutPromise + .then((layout) => this.onLayout(layout)); } onLayout(subviews: Array) { - this.setState({ subviews }); + this.setState({ subviews }, () => this.forceUpdate()); } getChildContext(): { subviews: ?Array } { @@ -93,6 +122,7 @@ class Superview extends Component { const newProps = { style: { width, height, ...style} }; + return createElement(container, newProps, children); } } diff --git a/test/client/spec/components/autodom.spec.jsx b/test/client/spec/components/autodom.spec.jsx index 4a861d4..0e87c72 100644 --- a/test/client/spec/components/autodom.spec.jsx +++ b/test/client/spec/components/autodom.spec.jsx @@ -1,7 +1,8 @@ /* eslint-env mocha */ import React, { PropTypes } from "react"; import { mount } from "enzyme"; -import AutoDOM, { whitelist } from "src/autodom"; +import { AutoDOM, animateDOM, whitelist } from "src/autodom"; +import { Motion, spring, presets } from "react-motion"; describe("AutoDOM", () => { whitelist.forEach((element) => { @@ -33,4 +34,82 @@ describe("AutoDOM", () => { expect(style).to.have.property("left", "0px"); }); }); + + it("can provide animated versions of its components", () => { + // TODO: we should use mockRAF like react-motion does to + // test that the tweening actually works. As of now, this + // is more of a smoke test that the component actually + // instantiates correctly. + const mountOptions = { + context: { + subviews: { + foo: { + layout: { + width: 300, + height: 45, + top: 20, + right: 100, + bottom: 50, + left: 10 + } + } + } + }, + childContextTypes: { + subviews: PropTypes.object + } + }; + + const MotionAutoDOM = animateDOM({ + animatorClass: Motion, + animatorProps: (layout) => ({ + style: { + width: spring(layout.width, presets.wobbly), + height: spring(layout.height, presets.wobbly), + top: spring(layout.top, presets.wobbly), + right: spring(layout.right, presets.wobbly), + bottom: spring(layout.bottom, presets.wobbly), + left: spring(layout.left, presets.wobbly) + } + }) + }); + + const result = mount( + + Whoa this is an animated subview!!! + , + mountOptions + ); + + expect(result.find(Motion)).to.have.lengthOf(1); + expect(result.find("p")).to.have.lengthOf(1); + + result.setContext({ + subviews: { + foo: { + layout: { + width: 300, + height: 45, + top: 10, + right: 50, + bottom: 250, + left: 100 + } + } + } + }); + + expect(result.context().subviews.foo.layout).to.deep.equal({ + width: 300, + height: 45, + top: 10, + right: 50, + bottom: 250, + left: 100 + }); + }); }); diff --git a/test/client/spec/components/autosvg.spec.jsx b/test/client/spec/components/autosvg.spec.jsx index de68f0b..dc3c28a 100644 --- a/test/client/spec/components/autosvg.spec.jsx +++ b/test/client/spec/components/autosvg.spec.jsx @@ -1,6 +1,7 @@ import React, { PropTypes } from "react"; import { mount } from "enzyme"; -import AutoSVG from "src/autosvg"; +import { AutoSVG, animateSVG } from "src/autosvg"; +import { Motion, spring, presets } from "react-motion"; const mountOptions = { context: { @@ -80,4 +81,82 @@ describe("AutoSVG", () => { expect(node.getAttribute("rx")).to.equal((width / 2).toString(10)); expect(node.getAttribute("ry")).to.equal((height / 2).toString(10)); }); + + it("can provide animated versions of its components", () => { + // TODO: we should use mockRAF like react-motion does to + // test that the tweening actually works. As of now, this + // is more of a smoke test that the component actually + // instantiates correctly. + const animatedMountOptions = { + context: { + subviews: { + foo: { + layout: { + width: 300, + height: 45, + top: 20, + right: 100, + bottom: 50, + left: 10 + } + } + } + }, + childContextTypes: { + subviews: PropTypes.object + } + }; + + const MotionAutoSVG = animateSVG({ + animatorClass: Motion, + animatorProps: (layout) => ({ + style: { + width: spring(layout.width, presets.wobbly), + height: spring(layout.height, presets.wobbly), + top: spring(layout.top, presets.wobbly), + right: spring(layout.right, presets.wobbly), + bottom: spring(layout.bottom, presets.wobbly), + left: spring(layout.left, presets.wobbly) + } + }) + }); + + const result = mount( + + Whoa this is an animated subview!!! + , + animatedMountOptions + ); + + expect(result.find(Motion)).to.have.lengthOf(1); + expect(result.find("rect")).to.have.lengthOf(1); + + result.setContext({ + subviews: { + foo: { + layout: { + width: 300, + height: 45, + top: 10, + right: 50, + bottom: 250, + left: 100 + } + } + } + }); + + expect(result.context().subviews.foo.layout).to.deep.equal({ + width: 300, + height: 45, + top: 10, + right: 50, + bottom: 250, + left: 100 + }); + }); }); diff --git a/test/client/spec/components/engine.spec.jsx b/test/client/spec/components/engine.spec.jsx index dc79f4c..36750f4 100644 --- a/test/client/spec/components/engine.spec.jsx +++ b/test/client/spec/components/engine.spec.jsx @@ -167,7 +167,43 @@ describe("Engine", () => { expect(layout).to.have.property("top"); expect(layout).to.have.property("right"); expect(layout).to.have.property("bottom"); - expect(layout).to.have.property("left"); + expect(layout).to.have.property("left", 100); + }); + + it("should remove constraints from the view", () => { + const engine = new Engine(); + engine.registerView({ + viewName: "test", + size: { + width: 200, + height: 150 + } + }); + + const subviews = engine.addConstraints({ + viewName: "test", + constraints: [ + constrain.subview("testSubview").centerX + .to.equal.superview.centerX.build() + ] + }); + + const { layout } = subviews.testSubview; + + expect(layout).to.have.property("left", 100); + + const subviewsWithoutConstraints = engine.removeConstraints({ + viewName: "test", + constraints: [ + constrain.subview("testSubview").centerX + .to.equal.superview.centerX.build() + ] + }); + + const { layout: layoutWithoutConstraints } = + subviewsWithoutConstraints.testSubview; + + expect(layoutWithoutConstraints).to.have.property("left", 0); }); it("should add intrinsic width and height to a subview", () => {