5

Google’s paper / material design http://www.google.com/design/spec/material-design/introduction.html is a really clean look that I think is going to see a lot of use. Polymer has a bunch of “paper-elements” ready to go and the web community is already playing with different ways to implement it. For this question I’m specifically looking at the button click effect.

It has a ripple of activation color that radiates from your click. Here is polymer’s example: http://www.polymer-project.org/components/paper-elements/demo.html#paper-button , here is a css jquery example: http://thecodeplayer.com/walkthrough/ripple-click-effect-google-material-design

My question is how to go about implementing it?

Taking a look at the polymer example When you mousedown it radiates a background color shift maybe instead of the colored opacity ripple in the other example. It holds when it reaches it’s limit and then on mouseup it quickly fades out.

Since I could easily see the code behind the second example I tried implementing it in a similar fashion as it had but with the exception of using touch events instead of click since I wanted it to hold the effect if all i did was touch but not release.

I tried scaling, transitioning the position setting the opacity but getting the placement and the effect of radiating outwards from the point of touch was beyond me or at least from the time I’ve invested so far. In truth I’m just under experienced in the animation department in general.

Any thoughts on how to implement it?

aintnorest
  • 1,326
  • 2
  • 13
  • 20

3 Answers3

4

I also wanted this effect, but I've not seen any implementations either. I decided to go with a CSS radial gradient in the button's background-image. I'm centering the ripple (the gradient's circle) at the touch/mouse point. I extended the Surface module in order to hook into the render cycle.

There are two Transitionables, one for the diameter of the gradient and one for gradient opacity. Both of these are reset after the interaction. When the user clicks on a button, the Surface stores the X and Y offset and then transitions the gradient diameter to its max value. When the user releases the button, it transitions the gradient opacity to 0.

The render cycle is constantly setting the background-image to a radial gradient with the circle at the X and Y offset, and getting the opacity and gradient diameter from the two Transitionables.

I can't tell you whether I've implemented the ripple button effect using best practices, but I like the result.

var Surface = require('famous/core/Surface');
var Timer = require('famous/utilities/Timer');
var Transitionable = require('famous/transitions/Transitionable');

// Extend the button surface to tap into .render()
// Probably should include touch events
function ButtonSurface() {
    Surface.apply(this, arguments);

    this.gradientOpacity = new Transitionable(0.1);
    this.gradientSize = new Transitionable(0);
    this.offsetX = 0;
    this.offsetY = 0;

    this.on('mousedown', function (data) {
        this.offsetX = (data.offsetX || data.layerX) + 'px';
        this.offsetY = (data.offsetY || data.layerY) + 'px';

        this.gradientOpacity.set(0.1);
        this.gradientSize.set(0);
        this.gradientSize.set(100, {
            duration: 300,
            curve: 'easeOut'
        });
    }.bind(this));

    this.on('mouseup', function () {
        this.gradientOpacity.set(0, {
            duration: 300,
            curve: 'easeOut'
        });
    });

    this.on('mouseleave', function () {
        this.gradientOpacity.set(0, {
            duration: 300,
            curve: 'easeOut'
        });
    });
}

ButtonSurface.prototype = Object.create(Surface.prototype);
ButtonSurface.prototype.constructor = ButtonSurface;

ButtonSurface.prototype.render = function () {
    var gradientOpacity = this.gradientOpacity.get();
    var gradientSize = this.gradientSize.get();
    var fadeSize = gradientSize * 0.75;

    this.setProperties({
        backgroundImage: 'radial-gradient(circle at ' + this.offsetX + ' ' + this.offsetY + ', rgba(0,0,0,' + gradientOpacity + '), rgba(0,0,0,' + gradientOpacity + ') ' + gradientSize + 'px, rgba(255,255,255,' + gradientOpacity + ') ' + gradientSize + 'px)'
    });

    // return what Surface expects
    return this.id;
};

You can check out my fiddle here.

Clay Smith
  • 56
  • 5
  • Nice. I've had family and work things and have been bad at keeping up on SO. I got this to work a little bit ago but had wanted to clean up the code and make it more customizable. I'll post what i have right after. The offset was a pain for me because I didn't use a css property to make it happen which I have to say Is way cleaner way to do it don't know why I didn't think of it. To much going on. Great work. I have the checkbox and an almost done version of the input ill try and get up as well. I've tested mine a few places but it didn't want to work in a fiddle for some reason. – aintnorest Aug 29 '14 at 05:51
  • I'm glad you like it. I see you went with border-radius in your version. A third option is to use a CanvasSurface, which is actually simpler if you're already using a canvas. In my app I needed both kinds, since I have some custom canvas controls and some regular surfaces that I want to ripple. I was able to copy most of the logic over. In canvas render cycle, you draw an full arc at the offset point and simply give it a rgba fillStyle. The result is cleaner than the radial gradient, but for me, it's not practical for every button. – Clay Smith Aug 29 '14 at 21:37
  • 1
    I'm playing with yours. I want the shadows and also the ability to disable enable the button. http://jsfiddle.net/cjalatorre/zr2m5d88/ – aintnorest Aug 30 '14 at 23:12
  • Any thoughts on why an array join wouldn't work instead of the string concatenation your doing for the radial-gradient. – aintnorest Aug 31 '14 at 00:17
  • Your updated fiddle doesn't work for touch. Touch doesn't pass back offset. It's working on the fiddle I posted earlier – aintnorest Aug 31 '14 at 01:38
  • Ah, you're right. I forgot about Fastclick adding a custom click event. The problem is, you're only getting a single blip rather than the full experience that the mousedown/mouseup pair provides. I'm not sure how to get around that. Polymer does somehow. Also, I think string concatenation vs. array join is a personal preference. The performance difference is negligible. – Clay Smith Aug 31 '14 at 03:06
1

Clay Awesome work love your version I'll probably tweak it a little and use it instead of my own.

define(function(require, exports, module) {

var Engine          = require('famous/core/Engine');
var Surface          = require('famous/core/Surface');
var Modifier         = require('famous/core/Modifier');
var StateModifier = require('famous/modifiers/StateModifier');
var Transform        = require('famous/core/Transform');
var View             = require('famous/core/View');
var Transitionable = require('famous/transitions/Transitionable');
var ImageSurface     = require("famous/surfaces/ImageSurface");
var OptionsManager = require('famous/core/OptionsManager');
var ContainerSurface = require("famous/surfaces/ContainerSurface");
var EventHandler = require('famous/core/EventHandler');
var RenderNode  = require('famous/core/RenderNode');
var Draggable   = require('famous/modifiers/Draggable');
var Easing      = require('famous/transitions/Easing');

function PaperButton(options) {
    View.apply(this, arguments);

    this.options = Object.create(PaperButton.DEFAULT_OPTIONS);
    this.optionsManager = new OptionsManager(this.options);
    if (options) this.optionsManager.patch(options);

    this.rootModifier = new StateModifier({
        size:this.options.size
    });

    this.mainNode = this.add(this.rootModifier);

    this._eventOutput = new EventHandler();
    EventHandler.setOutputHandler(this, this._eventOutput);

    _createControls.call(this);
    this.refresh();
};

PaperButton.prototype = Object.create(View.prototype);
PaperButton.prototype.constructor = PaperButton;

PaperButton.prototype.refresh = function() {
    var _inactiveBackground = 'grey';
    var _activeBackground = this.options.backgroundColor + '0.8)';
    this.surfaceSync.setProperties({boxShadow:_makeBoxShadow(this.options.enabled ? _droppedShadow : _noShadow)});
    this.surfaceSync.setProperties({background:_setBackground(this.options.enabled ? _activeBackground: _inactiveBackground)});
};

PaperButton.prototype.getEnabled = function() {
    return this.options.enabled;
};

PaperButton.prototype.setEnabled = function(enabled) {
    if(enabled == this.options.enabled) { return; }
    this.options.enabled = enabled;
    this.refresh();
};

PaperButton.DEFAULT_OPTIONS = {
    size:[269,50],//size of the button
    content:'Button',//button text
    backgroundColor:'rgba(68, 135, 250,',//rgba values only, cliped after the third values comma
    color:'white',//text color
    fontSize:'21px',
    enabled: true,
};

var _width = window.innerWidth; 
var _height = window.innerHeight;

var _noShadow = [0,0,0,0,0];
var _droppedShadow = [0,2,8,0,0.8];
var _liftedShadow = [0,5,15,0,0.8];
var _compareShadows = function(left, right) {
    var i = left.length;
    while(i>0) {
        if(left[i]!=right[i--]){ 
            return false;
        }
    }
    return true;
};

var _boxShadow = ['', 'px ', '', 'px ', '', 'px ', '', 'px rgba(0,0,0,', '', ')'];
var _makeBoxShadow = function(data) {
    _boxShadow[0] = data[0];
    _boxShadow[2] = data[1];
    _boxShadow[4] = data[2];
    _boxShadow[6] = data[3];
    _boxShadow[8] = data[4];
    return _boxShadow.join('');
};
var _setBackground = function(data) {
    return data;
};

var _animateShadow = function(initial, target, transition, comparer, callback) {
    var _initial = initial;
    var _target = target;
    var _transition = transition;
    var _current = initial;
    var _transitionable = new Transitionable(_current);
    var _handler;
    var _prerender = function(goal) {
        return function() {
            _current = _transitionable.get();
            callback(_current);
            if (comparer(_current, goal)) {
            //if (_current == _target || _current == _initial) {
                Engine.removeListener('prerender', _handler);
            }
        };
    };
    return {
        play: function() {
            // 
            //if(!this.options.enabled) { return; }
            _transitionable.halt();
            _transitionable.set(_target, _transition);
            _handler = _prerender(_target);
            Engine.on('prerender', _handler);
        },
        rewind: function() {
            //
            //if(!this.options.enabled) { return; }
            _transitionable.halt();
            _transitionable.set(_initial, _transition);
            _handler = _prerender(_initial);
            Engine.on('prerender', _handler);
        },
    }
}

function _createControls() {
    var self = this;

    var _container = new ContainerSurface({
        size:self.options.size,
        properties:{
            overflow:'hidden'
        }
    });
    this.mainNode.add(_container);

    var clicked = new Surface({
        size:[200,200],
        properties:{
            background:'blue',
            borderRadius:'200px',
            display:'none'
        }
    });
    clicked.mod = new StateModifier({
        origin:[0.5,0.5]
    });
    _container.add(clicked.mod).add(clicked);

    this.surfaceSync = new Surface({
        size:self.options.size,
        content:self.options.content,
        properties:{
            lineHeight:self.options.size[1] + 'px',
            textAlign:'center',
            fontWeight:'600',
            background:self.options.backgroundColor + '0.8)',
            color:self.options.color,
            fontSize:self.options.fontSize,
        }
    });
    this.mainNode.add(this.surfaceSync);
    this.surfaceSync.on('touchstart', touchEffect);
    this.surfaceSync.on('touchend', endTouchEffect);
    clicked.mod.setTransform(
            Transform.scale(-1, -1, -1),
            { duration : 0, curve: Easing.outBack }
        );


    var animator = _animateShadow(_droppedShadow, _liftedShadow, { duration : 500, curve: Easing.outBack }, _compareShadows, function(data) {
        if(!this.options.enabled) { return; }
        this.surfaceSync.setProperties({boxShadow:_makeBoxShadow(data)});
    }.bind(this));


    function touchEffect(e){
        var temp = e.target.getBoundingClientRect();
        var size = this.getSize();

        var offsetY = e.changedTouches[0].pageY - (temp.bottom - (size[1] / 2));
        var offsetX = e.changedTouches[0].pageX - (temp.right - (size[0] / 2));

        clicked.setProperties({left:offsetX+'px',top: offsetY+'px',display:'block'});

        var shadowTransitionable = new Transitionable([0,2,8,-1,0.65]);
        clicked.mod.setTransform(
            Transform.scale(2, 2, 2),
            { duration : 350, curve: Easing.outBack }
        );
        animator.play();
    };
    function endTouchEffect(){
        clicked.mod.setTransform(
            Transform.scale(-1, -1, -1),
            { duration : 300, curve: Easing.outBack }
        );
        clicked.setProperties({display:'none'});
        animator.rewind();
    };

};
module.exports = PaperButton;
});
aintnorest
  • 1,326
  • 2
  • 13
  • 20
0

Update Clay Smith's answer to satisfy mobile environment.

Actually I use this ButtonSuface on Phonegap/Cordova. Works great.

define(function(require, exports, module) {
var Surface        = require('famous/core/Surface');
var Timer          = require('famous/utilities/Timer');
var Transitionable = require('famous/transitions/Transitionable');

// Extend the button surface to tap into .render()
// Probably should include touch events
function ButtonSurface() {
    Surface.apply(this, arguments);

    this.gradientOpacity = new Transitionable(0);
    this.gradientSize = new Transitionable(0);
    this.offsetX = 0;
    this.offsetY = 0;

    this.on('touchstart', function (data) {
        this.offsetX = (data.targetTouches[0].clientX - this._element.getBoundingClientRect().left) + 'px';
        this.offsetY = (data.targetTouches[0].clientY - this._element.getBoundingClientRect().top) + 'px';

        this.gradientOpacity.set(0.2);
        this.gradientSize.set(0);
        this.gradientSize.set(100, {
            duration: 250,
            curve: 'easeOut'
        });
    });

    this.on('touchend', function (data) {
        this.gradientOpacity.set(0, {
            duration: 250,
            curve: 'easeOut'
        });
    });

}

ButtonSurface.prototype = Object.create(Surface.prototype);
ButtonSurface.prototype.constructor = ButtonSurface;

ButtonSurface.prototype.render = function () {
    var gradientOpacity = this.gradientOpacity.get();
    var gradientSize = this.gradientSize.get();
    var fadeSize = gradientSize * 0.75;

    this.setProperties({
        backgroundImage: 'radial-gradient(circle at ' + this.offsetX + ' ' + this.offsetY + ', rgba(0,0,0,' + gradientOpacity + '), rgba(0,0,0,' + gradientOpacity + ') ' + gradientSize + 'px, rgba(255,255,255,' + gradientOpacity + ') ' + gradientSize + 'px)'
    });

    // return what Surface expects
    return this.id;
};

module.exports= ButtonSurface;
});
Kumquat601
  • 106
  • 3
  • 5