Delay animations until attached (#7370)

* Delay animations until attached
* Detect container detachment
This commit is contained in:
Jukka Kurkela 2020-05-21 00:45:44 +03:00 committed by GitHub
parent 51be344717
commit cfb5fba527
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 166 additions and 133 deletions

View File

@ -141,7 +141,6 @@ options: {
Animation system was completely rewritten in Chart.js v3. Each property can now be animated separately. Please see [animations](../configuration/animations.md) docs for details.
#### Customizability
* `custom` attribute of elements was removed. Please use scriptable options
@ -169,6 +168,7 @@ Animation system was completely rewritten in Chart.js v3. Each property can now
While the end-user migration for Chart.js 3 is fairly straight-forward, the developer migration can be more complicated. Please reach out for help in the #dev [Slack](https://chartjs-slack.herokuapp.com/) channel if tips on migrating would be helpful.
Some of the biggest things that have changed:
* There is a completely rewritten and more performant animation system.
* `Element._model` and `Element._view` are no longer used and properties are now set directly on the elements. You will have to use the method `getProps` to access these properties inside most methods such as `inXRange`/`inYRange` and `getCenterPoint`. Please take a look at [the Chart.js-provided elements](https://github.com/chartjs/Chart.js/tree/master/src/elements) for examples.
* When building the elements in a controller, it's now suggested to call `updateElement` to provide the element properties. There are also methods such as `getSharedOptions` and `includeOptions` that have been added to skip redundant computation. Please take a look at [the Chart.js-provided controllers](https://github.com/chartjs/Chart.js/tree/master/src/controllers) for examples.
@ -187,6 +187,7 @@ A few changes were made to controllers that are more straight-forward, but will
The following properties and methods were removed:
#### Chart
* `Chart.borderWidth`
* `Chart.chart.chart`
* `Chart.Bar`. New charts are created via `new Chart` and providing the appropriate `type` parameter
@ -411,3 +412,4 @@ The APIs listed in this section have changed in signature or behaviour from vers
* `Chart.platform` is no longer the platform object used by charts. Every chart instance now has a separate platform instance.
* `Chart.platforms` is an object that contains two usable platform classes, `BasicPlatform` and `DomPlatform`. It also contains `BasePlatform`, a class that all platforms must extend from.
* If the canvas passed in is an instance of `OffscreenCanvas`, the `BasicPlatform` is automatically used.
* `isAttached` method was added to platform.

View File

@ -212,7 +212,7 @@ export default class Chart {
this.active = undefined;
this.lastActive = [];
this._lastEvent = undefined;
/** @type {{resize?: function}} */
/** @type {{attach?: function, detach?: function, resize?: function}} */
this._listeners = {};
this._sortedMetasets = [];
this._updating = false;
@ -221,6 +221,7 @@ export default class Chart {
this.$plugins = undefined;
this.$proxies = {};
this._hiddenIndices = {};
this.attached = true;
// Add the chart instance to the global namespace
Chart.instances[me.id] = me;
@ -248,7 +249,9 @@ export default class Chart {
Animator.listen(me, 'progress', onAnimationProgress);
me._initialize();
me.update();
if (me.attached) {
me.update();
}
}
/**
@ -341,7 +344,8 @@ export default class Chart {
options.onResize(me, newSize);
}
me.update('resize');
// Only apply 'resize' mode if we are attached, else do a regular update.
me.update(me.attached && 'resize');
}
}
@ -664,7 +668,7 @@ export default class Chart {
};
if (Animator.has(me)) {
if (!Animator.running(me)) {
if (me.attached && !Animator.running(me)) {
Animator.start(me);
}
} else {
@ -938,24 +942,57 @@ export default class Chart {
bindEvents() {
const me = this;
const listeners = me._listeners;
const platform = me.platform;
const _add = (type, listener) => {
platform.addEventListener(me, type, listener);
listeners[type] = listener;
};
const _remove = (type, listener) => {
if (listeners[type]) {
platform.removeEventListener(me, type, listener);
delete listeners[type];
}
};
let listener = function(e) {
me._eventHandler(e);
};
helpers.each(me.options.events, (type) => {
me.platform.addEventListener(me, type, listener);
listeners[type] = listener;
});
helpers.each(me.options.events, (type) => _add(type, listener));
if (me.options.responsive) {
listener = function(width, height) {
listener = (width, height) => {
if (me.canvas) {
me.resize(false, width, height);
}
};
me.platform.addEventListener(me, 'resize', listener);
listeners.resize = listener;
let detached; // eslint-disable-line prefer-const
const attached = () => {
_remove('attach', attached);
me.resize();
me.attached = true;
_add('resize', listener);
_add('detach', detached);
};
detached = () => {
me.attached = false;
_remove('resize', listener);
_add('attach', attached);
};
if (platform.isAttached(me.canvas)) {
attached();
} else {
detached();
}
} else {
me.attached = true;
}
}

View File

@ -126,13 +126,14 @@ export function getRelativePosition(evt, chart) {
};
}
function fallbackIfNotValid(measure, fallback) {
return typeof measure === 'number' ? measure : fallback;
}
export function getMaximumWidth(domNode) {
const container = _getParentNode(domNode);
if (!container) {
if (typeof domNode.clientWidth === 'number') {
return domNode.clientWidth;
}
return domNode.width;
return fallbackIfNotValid(domNode.clientWidth, domNode.width);
}
const clientWidth = container.clientWidth;
@ -147,10 +148,7 @@ export function getMaximumWidth(domNode) {
export function getMaximumHeight(domNode) {
const container = _getParentNode(domNode);
if (!container) {
if (typeof domNode.clientHeight === 'number') {
return domNode.clientHeight;
}
return domNode.height;
return fallbackIfNotValid(domNode.clientHeight, domNode.height);
}
const clientHeight = container.clientHeight;

View File

@ -48,6 +48,14 @@ export default class BasePlatform {
getDevicePixelRatio() {
return 1;
}
/**
* @param {HTMLCanvasElement} canvas
* @returns {boolean} true if the canvas is attached to the platform, false if not.
*/
isAttached(canvas) { // eslint-disable-line no-unused-vars
return true;
}
}
/**

View File

@ -172,51 +172,17 @@ function throttled(fn, thisArg) {
};
}
/**
* Watch for resize of `element`.
* Calling `fn` is limited to once per animation frame
* @param {Element} element - The element to monitor
* @param {function} fn - Callback function to call when resized
*/
function watchForResize(element, fn) {
const resize = throttled((width, height) => {
const w = element.clientWidth;
fn(width, height);
if (w < element.clientWidth) {
// If the container size shrank during chart resize, let's assume
// scrollbar appeared. So we resize again with the scrollbar visible -
// effectively making chart smaller and the scrollbar hidden again.
// Because we are inside `throttled`, and currently `ticking`, scroll
// events are ignored during this whole 2 resize process.
// If we assumed wrong and something else happened, we are resizing
// twice in a frame (potential performance issue)
fn();
}
}, window);
// @ts-ignore until https://github.com/Microsoft/TypeScript/issues/28502 implemented
const observer = new ResizeObserver(entries => {
const entry = entries[0];
resize(entry.contentRect.width, entry.contentRect.height);
});
observer.observe(element);
return observer;
}
/**
* Detect attachment of `element` or its direct `parent` to DOM
* @param {Element} element - The element to watch for
* @param {function} fn - Callback function to call when attachment is detected
* @return {MutationObserver}
*/
function watchForAttachment(element, fn) {
function createAttachObserver(chart, type, listener) {
const canvas = chart.canvas;
const container = canvas && _getParentNode(canvas);
const element = container || canvas;
const observer = new MutationObserver(entries => {
const parent = _getParentNode(element);
entries.forEach(entry => {
for (let i = 0; i < entry.addedNodes.length; i++) {
const added = entry.addedNodes[i];
if (added === element || added === parent) {
fn(entry.target);
listener(entry.target);
}
}
});
@ -225,79 +191,76 @@ function watchForAttachment(element, fn) {
return observer;
}
/**
* Watch for detachment of `element` from its direct `parent`.
* @param {Element} element - The element to watch
* @param {function} fn - Callback function to call when detached.
* @return {MutationObserver=}
*/
function watchForDetachment(element, fn) {
const parent = _getParentNode(element);
if (!parent) {
function createDetachObserver(chart, type, listener) {
const canvas = chart.canvas;
const container = canvas && _getParentNode(canvas);
if (!container) {
return;
}
const observer = new MutationObserver(entries => {
entries.forEach(entry => {
for (let i = 0; i < entry.removedNodes.length; i++) {
if (entry.removedNodes[i] === element) {
fn();
if (entry.removedNodes[i] === canvas) {
listener();
break;
}
}
});
});
observer.observe(parent, {childList: true});
observer.observe(container, {childList: true});
return observer;
}
/**
* @param {{ [x: string]: any; resize?: any; detach?: MutationObserver; attach?: MutationObserver; }} proxies
* @param {string} type
*/
function removeObserver(proxies, type) {
const observer = proxies[type];
function createResizeObserver(chart, type, listener) {
const canvas = chart.canvas;
const container = canvas && _getParentNode(canvas);
if (!container) {
return;
}
const resize = throttled((width, height) => {
const w = container.clientWidth;
listener(width, height);
if (w < container.clientWidth) {
// If the container size shrank during chart resize, let's assume
// scrollbar appeared. So we resize again with the scrollbar visible -
// effectively making chart smaller and the scrollbar hidden again.
// Because we are inside `throttled`, and currently `ticking`, scroll
// events are ignored during this whole 2 resize process.
// If we assumed wrong and something else happened, we are resizing
// twice in a frame (potential performance issue)
listener();
}
}, window);
// @ts-ignore until https://github.com/microsoft/TypeScript/issues/37861 implemented
const observer = new ResizeObserver(entries => {
const entry = entries[0];
resize(entry.contentRect.width, entry.contentRect.height);
});
observer.observe(container);
return observer;
}
function releaseObserver(canvas, type, observer) {
if (observer) {
observer.disconnect();
proxies[type] = undefined;
}
}
/**
* @param {{ resize?: any; detach?: MutationObserver; attach?: MutationObserver; }} proxies
*/
function unlistenForResize(proxies) {
removeObserver(proxies, 'attach');
removeObserver(proxies, 'detach');
removeObserver(proxies, 'resize');
}
function createProxyAndListen(chart, type, listener) {
const canvas = chart.canvas;
const proxy = throttled((event) => {
// This case can occur if the chart is destroyed while waiting
// for the throttled function to occur. We prevent crashes by checking
// for a destroyed chart
if (chart.ctx !== null) {
listener(fromNativeEvent(event, chart));
}
}, chart);
/**
* @param {HTMLCanvasElement} canvas
* @param {{ resize?: any; detach?: MutationObserver; attach?: MutationObserver; }} proxies
* @param {function} listener
*/
function listenForResize(canvas, proxies, listener) {
// Helper for recursing when canvas is detached from it's parent
const detached = () => listenForResize(canvas, proxies, listener);
addListener(canvas, type, proxy);
// First make sure all observers are removed
unlistenForResize(proxies);
// Then check if we are attached
const container = _getParentNode(canvas);
if (container) {
// The canvas is attached (or was immediately re-attached when called through `detached`)
proxies.resize = watchForResize(container, listener);
proxies.detach = watchForDetachment(canvas, detached);
} else {
// The canvas is detached
proxies.attach = watchForAttachment(canvas, () => {
// The canvas was attached.
removeObserver(proxies, 'attach');
const parent = _getParentNode(canvas);
proxies.resize = watchForResize(parent, listener);
proxies.detach = watchForDetachment(canvas, detached);
});
}
return proxy;
}
/**
@ -379,22 +342,14 @@ export default class DomPlatform extends BasePlatform {
// Can have only one listener per type, so make sure previous is removed
this.removeEventListener(chart, type);
const canvas = chart.canvas;
const proxies = chart.$proxies || (chart.$proxies = {});
if (type === 'resize') {
return listenForResize(canvas, proxies, listener);
}
const proxy = proxies[type] = throttled((event) => {
// This case can occur if the chart is destroyed while waiting
// for the throttled function to occur. We prevent crashes by checking
// for a destroyed chart
if (chart.ctx !== null) {
listener(fromNativeEvent(event, chart));
}
}, chart);
addListener(canvas, type, proxy);
const handlers = {
attach: createAttachObserver,
detach: createDetachObserver,
resize: createResizeObserver
};
const handler = handlers[type] || createProxyAndListen;
proxies[type] = handler(chart, type, listener);
}
@ -405,21 +360,32 @@ export default class DomPlatform extends BasePlatform {
removeEventListener(chart, type) {
const canvas = chart.canvas;
const proxies = chart.$proxies || (chart.$proxies = {});
if (type === 'resize') {
return unlistenForResize(proxies);
}
const proxy = proxies[type];
if (!proxy) {
return;
}
removeListener(canvas, type, proxy);
const handlers = {
attach: releaseObserver,
detach: releaseObserver,
resize: releaseObserver
};
const handler = handlers[type] || removeListener;
handler(canvas, type, proxy);
proxies[type] = undefined;
}
getDevicePixelRatio() {
return window.devicePixelRatio;
}
/**
* @param {HTMLCanvasElement} canvas
*/
isAttached(canvas) {
const container = _getParentNode(canvas);
return !!(container && _getParentNode(container));
}
}

View File

@ -1,3 +1,5 @@
import {DomPlatform} from '../../src/platform/platforms';
describe('Platform.dom', function() {
describe('context acquisition', function() {
@ -405,4 +407,24 @@ describe('Platform.dom', function() {
});
});
});
describe('isAttached', function() {
it('should detect detached when canvas is attached to DOM', function() {
var platform = new DomPlatform();
var canvas = document.createElement('canvas');
var div = document.createElement('div');
expect(platform.isAttached(canvas)).toEqual(false);
div.appendChild(canvas);
expect(platform.isAttached(canvas)).toEqual(false);
document.body.appendChild(div);
expect(platform.isAttached(canvas)).toEqual(true);
div.removeChild(canvas);
expect(platform.isAttached(canvas)).toEqual(false);
document.body.removeChild(div);
expect(platform.isAttached(canvas)).toEqual(false);
});
});
});