Reputation:
Many examples show using ResizeObserver
something like this
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
function draw() {
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.translate(width / 2, height / 2);
const size = Math.min(width, height);
ctx.beginPath();
ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const canvas = entry.target;
canvas.width = entry.contentBoxSize[0].inlineSize;
canvas.height = entry.contentBoxSize[0].blockSize;
draw();
}
})
observer.observe(canvas);
html, body {
height: 100%;
margin: 0;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
<canvas></canvas>
This works. The ResizeObserver
will fire once when started and then anytime the canvas's display size changes
But then, if you switch to a requestAnimationFrame
loop you'll see an issue
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
function draw() {
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.translate(width / 2, height / 2);
const size = Math.min(width, height);
ctx.beginPath();
ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
ctx.fill();
ctx.rotate(performance.now() * 0.001);
ctx.strokeStyle = 'red';
ctx.strokeRect(-5, -5, size / 4, 10);
ctx.restore();
}
function rAFLoop() {
draw();
requestAnimationFrame(rAFLoop);
}
requestAnimationFrame(rAFLoop);
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const canvas = entry.target;
canvas.width = entry.contentBoxSize[0].inlineSize;
canvas.height = entry.contentBoxSize[0].blockSize;
}
})
observer.observe(canvas);
html, body {
height: 100%;
margin: 0;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
<canvas></canvas>
Above, all I did was take the call to draw out of ResizeObserver
and call it in a requestAnimationFrame
loop (and added a little motion)
If you size the window now you'll see the circle flickers
How do I fix this?
Upvotes: 6
Views: 1353
Reputation:
The issue here is the spec says that ResizeObserver
callbacks happen after requestAnimationFrame callbacks. This means the order of operations in the example above is
There are several workarounds, all of them have issues or are semi-involved
ResizeObserver
too.This will work. Unfortunately this means you're drawing twice in a single frame. If your drawing is heavy then that would lead to the resize feeling sluggish
ResizeObserver
You can look up the display size of the canvas in multiple ways. (1) canvas.clientWidth
, canvas.clientHeight
(returns integers), (2) canvas.getBoundingClientRect()
(returns rational numbers)
The biggest downside of this is that if you didn't need or want a constantly running requestAnimationFrame
loop, now you will have one, and it will be eating battery life even if you're not updating anything.
For example:
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
function draw() {
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.translate(width / 2, height / 2);
const size = Math.min(width, height);
ctx.beginPath();
ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
ctx.fill();
ctx.rotate(performance.now() * 0.001);
ctx.strokeStyle = 'red';
ctx.strokeRect(-5, -5, size / 4, 10);
ctx.restore();
}
function rAFLoop() {
// Only set canvas.width and canvas.height
// if they need to be set because setting them is a
// heavy operation
if (canvas.width !== canvas.clientWidth ||
canvas.height !== canvas.clientHeight) {
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
}
draw();
requestAnimationFrame(rAFLoop);
}
requestAnimationFrame(rAFLoop);
html, body {
height: 100%;
margin: 0;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
<canvas></canvas>
Size the window and you'll see it no longer flickers
This solution will work but the those methods (canvas.clientWidth/height
and/or getBoundingClientRect
) only return CSS pixels, not device pixels. You can multiply either number by devicePixelRatio
but that will also not give you the correct answer all of the time. You can read about why in this answer: https://stackoverflow.com/a/72611819/128511
ResizeObserver
callback.We can guess if the canvas is going to resize if getBoundingClientRect
changes size. Even though we can't correctly convert from getBoundingClientRect
to device pixels, the value will change from the previous time we called it if the canvas has been resized.
Downsides: just like the previous idea you now have an endless loop eating battery, and also this does not work if you are making a framework with 3rd party code doing unknown resizes and unknown times.
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
function draw() {
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.translate(width / 2, height / 2);
const size = Math.min(width, height);
ctx.beginPath();
ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
ctx.fill();
ctx.rotate(performance.now() * 0.001);
ctx.strokeStyle = 'red';
ctx.strokeRect(-5, -5, size / 4, 10);
ctx.restore();
}
let lastSize = {};
function rAFLoop() {
const rect = canvas.getBoundingClientRect();
const willResize = lastSize.width !== rect.width || lastSize.height !== rect.height;
if (willResize) {
// don't draw since we'll draw in the resizeObserver callback
// but do record our size
lastSize = rect;
} else {
draw();
}
requestAnimationFrame(rAFLoop);
}
requestAnimationFrame(rAFLoop);
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const canvas = entry.target;
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
draw();
}
})
observer.observe(canvas, { box: 'device-pixel-content-box' });
html, body {
height: 100%;
margin: 0;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
<canvas></canvas>
With this solution we only render once per frame and we get the actual device pixel size
Note: the 3rd solution changed what it's observing from 'content-box'
, which is the default, to 'device-pixel-content-box'
. If you don't make this change, then, if the user zooms in or out you will not get a resize callback since from the POV the webpage, the size of the element doesn't change on zoom. It's still just a certain number of CSS pixels.
ResizeObserver
callback, resize the canvas in requestAnimationFrame
if the size changedThis solution means, given the order of operations at the top of this answer, you'll render the wrong size for 1 frame. Often that doesn't matter. In fact, run the example below and size the window and you likely won't see an issue.
The biggest downside of this one is that, if do not want to have an endless rAF loop, and your rendering depends on sizing in various ways (f.e. a UI), then you'll introduce 1-frame-behind visual glitches to your users.
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
function draw() {
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.translate(width / 2, height / 2);
const size = Math.min(width, height);
ctx.beginPath();
ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
ctx.fill();
ctx.rotate(performance.now() * 0.001);
ctx.strokeStyle = 'red';
ctx.strokeRect(-5, -5, size / 4, 10);
ctx.restore();
}
const desiredSize = { width: 300, height: 150 }; // initial size of canvas
function rAFLoop() {
const { width, height } = desiredSize;
// only resize if we really need to since setting canvas.width/height
// can be a heavy operation
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
draw();
requestAnimationFrame(rAFLoop);
}
requestAnimationFrame(rAFLoop);
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const canvas = entry.target;
desiredSize.width = entry.devicePixelContentBoxSize[0].inlineSize;
desiredSize.height = entry.devicePixelContentBoxSize[0].blockSize;
}
})
observer.observe(canvas, { box: 'device-pixel-content-box' });
html, body {
height: 100%;
margin: 0;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
<canvas></canvas>
If you size the window you likely won't see any issues with this solution. On the other hand, let's say you have sudden large changes. For example imagine you have an editor and you have an option to show/hide a pane so that the canvas area changes sizes drastically. Then, for one frame, you'll see an image the wrong size. We can demo this
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
function draw() {
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = 'red';
ctx.save();
ctx.translate(width / 2, height / 2);
const size = Math.min(width, height);
ctx.beginPath();
ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
ctx.fill();
ctx.stokeStyle = 'red';
ctx.lineWidth = 20;
ctx.strokeRect(-size / 2.5, -size / 2.5, size / 1.25, size / 1.25);
ctx.lineWidth = 3;
ctx.rotate(performance.now() * 0.001);
ctx.strokeStyle = '#000';
ctx.strokeRect(-5, -5, size / 4, 10);
ctx.restore();
}
const desiredSize = { width: 300, height: 150 }; // initial size of canvas
function rAFLoop() {
const { width, height } = desiredSize;
// only resize if we really need to since setting canvas.width/height
// can be a heavy operation
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
draw();
requestAnimationFrame(rAFLoop);
}
requestAnimationFrame(rAFLoop);
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const canvas = entry.target;
desiredSize.width = entry.devicePixelContentBoxSize[0].inlineSize;
desiredSize.height = entry.devicePixelContentBoxSize[0].blockSize;
}
})
observer.observe(canvas, { box: 'device-pixel-content-box' });
const uiElem = document.querySelector('#ui');
setInterval(() => {
ui.style.display = ui.style.display === '' ? 'none' : '';
}, 1000);
html, body {
height: 100%;
margin: 0;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
#outer {
display: flex;
width: 100%;
height: 100%;
}
#outer>* {
flex: 1 1 50%;
min-width: 0;
}
#ui {
display: flex;
justify-content: center;
align-items: center;
background-color: #888;
}
<div id="outer">
<div><canvas></canvas></div>
<div id="ui"><div>ui pane</div></div>
</div>
If you watch the example above closely you'll see one frame that's the wrong size when the ui pane is hidden or shown
Which solution you choose is up to your needs. If your drawing is not that heavy, drawing twice, once in rAF, once in the resize observer might be fine. Or, if you don't expect the user to resize often (note: this isn't about resizing just the window, plenty of webpages have panes with sliders that let you adjust the size of areas where all the same issues exist)
If you don't care about pixel perfection then the 2nd solution is fine too. For example, most 3d games, AFAIK, don't care about pixel perfection. They are already rendering bilinear filtered textures and often rendering to lower-resolutions and rescaling.
If you do care about pixel perfection then the 3rd solution works.
note: As of 2014-01-18 Safari still does not support devicePixelContentBoxSize
It would be great to have some basic web APIs to alleviate this problem and similar problems. Please leave your comments or upvotes on these issues to help DOM observation and code order/timing problems to be solved:
Upvotes: 3