Optimization of touch UI in a Tinder-like mobile app


#1

Hi,

I’m writing a mobile app, which has basically the same UI as the Tinder app. That means, there is a stack of “cards” on the screen, each card represents a user and you can swipe the top one. The exact behaviour is as follows

  • When you put your finger on the currently displayed card and you hold your finger and move it on the screen, the card follows the finger. That way you can swipe it to the sides to perform a “like” or “dislike”.
  • Also, while you move the card more to the sides, it starts to reveal the cards underneath and they come slightly more up to the foreground.

These two effects aren’t easy to implement. I came with a solution of my own, but it’s pretty slow. I wonder if it can be optimized further or if it’s the end and I reached a limit. I hope somebody here will come up with interesting ideas.

So here are details of the implementation:

  • I use React as the view layer
  • All the functionality that deals with the swiping is implemented in a single component, using it’s internal state.

Here’s the implementation of the component. I tried to comment it well, which should help understand it:

import React from 'react';
import ProfileInfo from './ProfileInfo.jsx';

const DRAG_THRESHOLD_PERCENTAGE = 30;

class VoteProfile extends React.Component {

    constructor(props) {
        super(props);

        this.handleMouseDown = this.handleMouseDown.bind(this);
        this.handleMouseMove = this.handleMouseMove.bind(this);
        this.handleMouseUp   = this.handleMouseUp.bind(this);

        this.state = {
            // indicates, if the user is moving his finger on the screen
            isDragging: false,
            // indicates, where the user put a finger on the screen, to compute the offset while he moves it
            startPosition: [0, 0],
            // relative position of the finger from the original position, where it was placed on the screen
            offset: {x: 0, y: 0},
            // while swiping to the side, the card can either rotate up or down, depending on the direction that
            // the user moved his finger first
            dragDirectionLocked: false,
            // 1 = "up", -1 = "down"
            dragDirection: 1
        };
    }

    componentDidMount() {
        const {user} = this.props;
        const voteProfile = document.getElementById('vote-profile-' + user._id);

        voteProfile.addEventListener('touchstart', this.handleMouseDown);
        window.addEventListener('touchmove', this.handleMouseMove);
        window.addEventListener('touchend', this.handleMouseUp);

        voteProfile.addEventListener('mousedown', this.handleMouseDown);
        window.addEventListener('mousemove', this.handleMouseMove);
        window.addEventListener('mouseup', this.handleMouseUp);

    }

    componentWillUnmount() {
        const {user} = this.props;
        const voteProfile = document.getElementById('vote-profile-' + user._id);

        voteProfile.removeEventListener('touchstart', this.handleMouseDown);
        window.removeEventListener('touchmove', this.handleMouseMove);
        window.removeEventListener('touchend', this.handleMouseUp);

        voteProfile.removeEventListener('mousedown', this.handleMouseDown);
        window.removeEventListener('mousemove', this.handleMouseMove);
        window.removeEventListener('mouseup', this.handleMouseUp);
    }

    /**
     * On mouse down (touch screen), save the absolute position of the finger as a starting position.
     * Also save the time to compute the duration, that the finger was placed on the screen.
     */
    handleMouseDown(e) {
        e.preventDefault();

        if (e.type === 'touchstart') {
            e = e.touches[0];
        }

        this.setState({
            clickAt: new Date().getTime(),
            isDragging: true,
            startPosition: [e.pageX, e.pageY]
        });
    }

    /**
     * Whenever the finger moves, compute and save the relative position of the finger to the starting position (offset).
     * If it's the first time user moved his finger since he touched the screen,
     * determine if the direction is up or down and save it.
     */
    handleMouseMove(e) {
        e.preventDefault();

        if (e.type === 'touchmove') {
            e = e.touches[0];
        }

        const {isDragging, startPosition: [dx, dy], dragDirectionLocked} = this.state;

        if (isDragging) {
            const offset = {x: e.pageX - dx, y: e.pageY - dy};

            // if direction of the rotation wasn't set and locked yet, set and lock it
            if (dragDirectionLocked) {
                this.setState({offset});
            } else {
                this.setState({
                    offset: offset,
                    dragDirection: offset.y < 0 ? -1 : 1,
                    dragDirectionLocked: true
                });
            }
        }
    }

    /**
     * When the finger leaves the screen, compute the duration of the finger touching the screen.
     * If the finger left the screen after less than 100 ms, consider it a click, not a swipe.
     */
    handleMouseUp() {
        const {clickAt} = this.state;
        const now = new Date().getTime();

        if (clickAt) {
            if (now - clickAt > 100) {
                this.handleVote();
            } else {
                this.handleClick();
            }
        }
    }

    /**
     * Compute, how many percent of the screen width the finger moved from it's starting position.
     * If it covered at least DRAG_THRESHOLD_PERCENTAGE percent, consider it a valid vote.
     */
    handleVote() {
        const {offset} = this.props;
        const windowWidth = window.innerWidth;
        const percentage  = (Math.abs(offset.x) / windowWidth) * 100;

        if (percentage >= DRAG_THRESHOLD_PERCENTAGE) {
            const {user, actions} = this.props;
            const like = offset.x > 0;

            this.resetState();
            actions.vote(user._id, like);
        } else {
            this.resetState();
        }
    }

    /**
     * In case of a click, redirect to a user's profile detail.
     */
    handleClick() {
        const {user} = this.props;
        const {push} = this.props.actions;

        this.resetState();

        push('/profile/detail/' + user._id);
    }

    /**
     * Helper method to reset the state after user's finger leaves the screen.
     */
    resetState() {
        this.setState({
            clickAt: null,
            isDragging: false,
            startPosition: [0, 0],
            offset: {x: 0, y: 0},
            dragDirection: 1,
            dragDirectionLocked: false
        });
    }

    /**
     * Compute the current styles of the card, depending on if the finger is touching the screen and the offset.
     * If the finger is touching the screen, relative position is set to the card to simulate th effect of
     * the card following the finger.
     * Also an arbitrary rotation effect is computed for the swiped card.
     */
    getStyleProfile() {
        const {index} = this.props;
        const {isDragging, dragDirection, offset: {x, y}} = this.state;

        if (index === 0 && isDragging) {
            const rotation = dragDirection * (x / 20);

            return {
                position: 'relative',
                left: x + 'px',
                top: y + 'px',
                transform: 'rotate(' + rotation + 'deg)',
                transformStyle: 'flat'
            };
        }

        return {};
    }

    /**
     * This is just to set the profile picture of the user as a background image.
     */
    getStylePicture() {
        const {user} = this.props;
        let profilePicture;

        if (user.profile.pictures.length > 0) {
            profilePicture = user.profile.pictures[0].link;
            return {'backgroundImage': `url(${profilePicture})`};
        }

        return {};
    }

    /**
     * When the user swipes the card to the side, a stamp appears on it, that says "LIKE" or "NOPE",
     * depending on the direction of the swipe.
     * Depending on how far to the side user swipes, the opacity of the stamp changes,
     * starting from 0 and going all the way to 1.
     * This method computes the opacity.
     */
    getStyleStamp(like) {
        const {index, offset: {x}} = this.props;
        const {isDragging} = this.state;

        if (index === 0 && isDragging) {
            const windowWidth      = window.innerWidth;
            const opacityThreshold = (windowWidth / 100) * DRAG_THRESHOLD_PERCENTAGE;

            // either like and x > 0 or !like and x < 0
            if (like === (x > 0)) {
                // if x is in the opacity threshold range, the opacity will be lower than 1
                return {
                    opacity: Math.min(Math.abs(x) / opacityThreshold, 1)
                };
            }
        }

        return {};
    }

    render() {
        const {user} = this.props;

        return (
            <div id={'vote-profile-' + user._id} className="vote-profile" style={this.getStyleProfile()}>
                <div className="vote-profile-picture">
                    <div className="vote-profile-picture-content" style={this.getStylePicture()}>
                        <div className="vote-profile-picture-stamp vote-profile-picture-stamp-like"
                             style={this.getStyleStamp(true)}
                        >
                            <div className="vote-profile-picture-stamp-wrapper">LIKE</div>
                        </div>
                        <div className="vote-profile-picture-stamp vote-profile-picture-stamp-dislike"
                             style={this.getStyleStamp(false)}
                        >
                            <div className="vote-profile-picture-stamp-wrapper">NOPE</div>
                        </div>
                    </div>
                </div>

                <ProfileInfo user={user}/>
            </div>
        );
    }
}

export default VoteProfile;

What I’m asking for is: can you come up with any way to further optimize this? Imo this is very “bare-bones” basic solution and I don’t know how this could be further optimized. If you don’t think it can be optimized, I would still be interested in your opinion on this, so please answer whatever comes to mind.

Thanks


#2

left isn’t GPU optimized. Try transform: translateX() and -webkit-transform: translateX() instead.