Pomax: Touch events, Reactjs, and Android. Good luck. |
We're doing a bit of prototype work over at the Mozilla Foundation, playing around with what possible future ways of interacting with makable web things could look like (can that be more vague?), and one of these prototypes takes the shape of dropping HTML elements onto a page and, photo book style, moving them around (or rather, moving, rotating, and scaling, using CSS3) without necessarily affecting the markup ordering.
And that works well! We're currently exploring React.js (which comes with a refreshing look at what programming for the web can look like) and so I figured I'd try my hand at the idea by writing a React component/mixin that could be used in conjunction with arbitrary content to magically make it movable, rotatable and scalable. And in desktop browsers, it works really well!
Unfortunately, we also need things to work on mobile devices, where there are no mouse cursors, and instead you have to work with touch. Touch changes some things (the CSS :hover state, for instance, becomes meaningless) but for the most part if your code worked with mousedown
, mousemove
and mouseup
, those map fairly straight forward to touchstart
, touchmove
and touchend
. Add the touch listeners and make them do the same as the mouse listeners, and done. Or, you would be, if these generated the same data. They don't, so you have a bit more work to do for getting the correct coordinates out of the touch events (mouse events have evt.clientX
, touch events are an array of possible multitouch, so you end up with evt.touches[0].pageX
, for instance). Still, entirely doable.
Unfortunately, things get weird when you do these things and then try to use them on, say, Android. Android has bugs when it comes touch events. Outside of the expected, that is. First, it turns out that Android won't fire off touchend
events, even if they occur, if you never told Android to "prevent the default behaviour" on a touchstart
or touchmove
. Why? Because if you don't, Android will treat the finger gesture first as what you needed to do, and then as "oh but the default behaviour should still happen, the user wants to scroll the page" and then the touchend that stops Android from listening to page scroll gets consumed and never sent on to your code. If you didn't know about that, you're wasting quite a bit of time figuring out what the heck is going on.
But now you know about that, so adding evt.preventDefault()
in your start and move handling should fix things, right? Well... no. It turns out there's another, far more magical, feature in Android that does what should reasonably be impossible in any programming setting. Have a look at this code:
var element = ...;
element.addEventListener("touchstart", handleTouchStart);
element.addEventListener("touchmove", handleTouchMove);
element.addEventListener("touchend", handleTouchEnd);
function touchStart(evt) {
console.log("touch started);
}
function touchMove(evt) {
console.log("touch move);
}
function touchEnd(evt) {
console.log("touch ended);
}
This works great. Loading pages with code like this on Android will show that all three events fire if you put down your finger, move it around a bit, and take it off the screen again. But, we might want to know where all those events happen, so let's write a helper function and modify the handlers:
function fixEvtCoords(evt) {
evt.clientX = evt.clientX || evt.touches[0].pageX;
evt.clientY = evt.clientY || evt.touches[0].pageY;
}
...
function touchStart(evt) {
fixEvtCoords(evt);
console.log("touch started at " + evt.clientX + "," + evt.clientY);
}
function touchMove(evt) {
fixEvtCoords(evt);
console.log("touch move at " + evt.clientX + "," + evt.clientY);
}
function touchStart(evt) {
fixEvtCoords(evt);
console.log("touch ended at " + evt.clientX + "," + evt.clientY);
}
That looks perfectly reasonable, and start and move now show the coordinates at which the events are generated. But touchend
no longer works... what? It gets more interesting: what if we don't fix the coordinates for the end event?
function touchStart(evt) {
console.log("touch ended at " + evt.clientX + "," + evt.clientY);
}
This logs "touch ended at undefined,undefined
", which makes sense because touch events don't have the .clientX
and .clientY
properties. So, let's change those to the real thing:
function touchStart(evt) {
console.log("touch ended at " + evt.touches[0].pageX + "," + evt.touches[0].pageY);
}
This won't actually do anything. There is nothing in .touches[0]
anymore, so there will be a JS error and the code won't run. So what do we do? The simplest solution is to rely on the fact that we're only using single finger interaction, and just assume that if a touchend
fired at all, we no longer have any fingers on the screen:
function touchStart(evt) {
console.log("touch ended");
}
This is weird for several reasons: if we want to deal with multi touch, how do we track which finger just stopped being on the screen? You'd be tempted to try something like this:
function touchStart(evt) {
console.log("touch ended", JSON.stringify(evt, false, 2));
}
To get an easy to debug bit of string data to tell us what's in that event, but if we do this, more JS errors and the log call will throw instead of logging useful data.
The worst is you just read this in a matter of a few minutes, but discovering all this, if you don't really work with Android all that much, is pretty much hours and hours of trying things, not understanding why they work on desktop but not on Android, trying more things, case reducing, starting from scratch, noticing things do work, slowly building things back up, noticing they break at some point, going back to where things weren't broken, and slowly figuring out what's going wrong because you home in on specific calls and patterns that just don't seem to work.
Over the course of 6 hours I went from not knowing these things to knowing both how to deal with this in the future, as well as knowing how to write my React code in such a way that touch events will propagate properly. Fun fact: if you're using React in an Android WebView "browser", there are some things you can do that work perfectly fine on desktop, and will not work at all on Android, too.
For instance, React has onTouchStart
, onTouchMove
and onTouchEnd
component event handlers, with augmented events to make sure every browser will work the same. That's great, except it has bugs. The event augmentation does something (and without looking at the React source code, I have no real idea what that something is) that breaks event propagation. So, this code doesn't work:
var Positionable = ... ({
render: function() {
return (
);
}
})
var RotationControls = ... ({
render: function() {
return (
...
);
}
})
var ScaleControls = ... ({
render: function() {
return (
...
);
}
})
You might think it would, but nope: not on Android. While this works fine on desktop, trying this on Android and tapping the RotationControls
element actually gets sent to the higher level Positionable
instead. No matter how much you tap, that touch event is not going to make it into the handler defined in RotationControls
to rotate our element. So, ultimately, despite React having code in place to make working with touch events nicer, we actually need to go back to the drawing board and use the good old low level addEventListener('touchstart', ...)
and friends in order to make sure that nothing interferes with event propagation.
var TouchMixin = {
componentDidMount: function() {
var localNode = this.getDOMNode();
localNode.addEventListener('touchstart', this.handleTouchStart);
},
componentWillUnmount: function() {
var localNode = this.getDOMNode();
localNode.removeEventListener('touchstart', this.handleTouchStart);
}
};
var Positionable = ... ({
mixins: [
TouchMixin
],
render: function() {
return (
);
}
})
With similar changes in RotationControls
and ScaleControls
. Fun!
But wait, there's more. The component I'm writing also has a ZIndexController
, which gives you two buttons for changing a number, and that number gets communicated up, and used as z-index for the element on the page:
var Positionable = ... ({
render: function() {
return (
{ this.props.children }
);
}
})
var ZIndexController = ... ({
getInitialState: function() {
return { zIndex: this.props.zIndex || 0 };
},
render: function() {
return (
layer position:
Комментировать | « Пред. запись — К дневнику — След. запись » | Страницы: [1] [Новые] |