Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a331c339 authored by Lucas Dupin's avatar Lucas Dupin Committed by Android (Google) Code Review
Browse files

Merge "Update ripple spec" into sc-dev

parents 81219cb7 33efde78
Loading
Loading
Loading
Loading
+85 −48
Original line number Diff line number Diff line
@@ -27,8 +27,9 @@ import android.graphics.Paint;
import android.graphics.RecordingCanvas;
import android.graphics.animation.RenderNodeAnimator;
import android.util.ArraySet;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.AnimationUtils;
import android.view.animation.LinearInterpolator;
import android.view.animation.PathInterpolator;

import java.util.function.Consumer;

@@ -36,32 +37,41 @@ import java.util.function.Consumer;
 * @hide
 */
public final class RippleAnimationSession {
    private static final int ENTER_ANIM_DURATION = 350;
    private static final int EXIT_ANIM_OFFSET = ENTER_ANIM_DURATION;
    private static final int EXIT_ANIM_DURATION = 350;
    private static final String TAG = "RippleAnimationSession";
    private static final int ENTER_ANIM_DURATION = 300;
    private static final int SLIDE_ANIM_DURATION = 450;
    private static final int EXIT_ANIM_DURATION = 300;
    private static final TimeInterpolator LINEAR_INTERPOLATOR = new LinearInterpolator();
    // Matches R.interpolator.fast_out_slow_in but as we have no context we can't just import that
    private static final TimeInterpolator DECELERATE_INTERPOLATOR = new DecelerateInterpolator();

    private static final TimeInterpolator PATH_INTERPOLATOR =
            new PathInterpolator(.2f, 0, 0, 1f);
    private Consumer<RippleAnimationSession> mOnSessionEnd;
    private AnimationProperties<Float, Paint> mProperties;
    private final AnimationProperties<Float, Paint> mProperties;
    private AnimationProperties<CanvasProperty<Float>, CanvasProperty<Paint>> mCanvasProperties;
    private Runnable mOnUpdate;
    private long mStartTime;
    private boolean mForceSoftware;
    private ArraySet<Animator> mActiveAnimations = new ArraySet(3);
    private final float mWidth, mHeight;
    private final ValueAnimator mSparkle = ValueAnimator.ofFloat(0, 1);
    private final ArraySet<Animator> mActiveAnimations = new ArraySet<>(3);

    RippleAnimationSession(@NonNull AnimationProperties<Float, Paint> properties,
            boolean forceSoftware) {
            boolean forceSoftware, float width, float height) {
        mProperties = properties;
        mForceSoftware = forceSoftware;
    }

    void end() {
        for (Animator anim: mActiveAnimations) {
            if (anim != null) anim.end();
        }
        mActiveAnimations.clear();
        mWidth = width;
        mHeight = height;

        mSparkle.addUpdateListener(anim -> {
            final long now = AnimationUtils.currentAnimationTimeMillis();
            final long elapsed = now - mStartTime - ENTER_ANIM_DURATION;
            final float phase = (float) elapsed / 1000f;
            mProperties.getShader().setSecondsOffset(phase);
            notifyUpdate();
        });
        mSparkle.setDuration(ENTER_ANIM_DURATION);
        mSparkle.setStartDelay(ENTER_ANIM_DURATION);
        mSparkle.setInterpolator(LINEAR_INTERPOLATOR);
        mSparkle.setRepeatCount(ValueAnimator.INFINITE);
    }

    @NonNull RippleAnimationSession enter(Canvas canvas) {
@@ -70,17 +80,19 @@ public final class RippleAnimationSession {
        } else {
            enterSoftware();
        }
        mStartTime = System.nanoTime();
        mStartTime = AnimationUtils.currentAnimationTimeMillis();
        return this;
    }

    @NonNull RippleAnimationSession exit(Canvas canvas) {
        mSparkle.end();
        if (isHwAccelerated(canvas)) exitHardware((RecordingCanvas) canvas);
        else exitSoftware();
        return this;
    }

    private void onAnimationEnd(Animator anim) {
        notifyUpdate();
        mActiveAnimations.remove(anim);
    }

@@ -92,7 +104,6 @@ public final class RippleAnimationSession {

    RippleAnimationSession setOnAnimationUpdated(@Nullable Runnable run) {
        mOnUpdate = run;
        mProperties.setOnChange(mOnUpdate);
        return this;
    }

@@ -122,14 +133,12 @@ public final class RippleAnimationSession {
    }

    private long computeDelay() {
        long currentTime = System.nanoTime();
        long timePassed =  (currentTime - mStartTime) / 1_000_000;
        long difference = EXIT_ANIM_OFFSET;
        return Math.max(difference - timePassed, 0);
        final long timePassed =  AnimationUtils.currentAnimationTimeMillis() - mStartTime;
        return Math.max((long) SLIDE_ANIM_DURATION - timePassed, 0);
    }

    private void notifyUpdate() {
        Runnable onUpdate = mOnUpdate;
        if (onUpdate != null) onUpdate.run();
        if (mOnUpdate != null) mOnUpdate.run();
    }

    RippleAnimationSession setForceSoftwareAnimation(boolean forceSw) {
@@ -153,7 +162,7 @@ public final class RippleAnimationSession {
            }
        });
        exit.setTarget(canvas);
        exit.setInterpolator(DECELERATE_INTERPOLATOR);
        exit.setInterpolator(LINEAR_INTERPOLATOR);

        long delay = computeDelay();
        exit.setStartDelay(delay);
@@ -161,36 +170,67 @@ public final class RippleAnimationSession {
        mActiveAnimations.add(exit);
    }

    private void enterHardware(RecordingCanvas can) {
    private void enterHardware(RecordingCanvas canvas) {
        AnimationProperties<CanvasProperty<Float>, CanvasProperty<Paint>>
                props = getCanvasProperties();
        RenderNodeAnimator expand =
                new RenderNodeAnimator(props.getProgress(), .5f);
        expand.setTarget(can);
        expand.setDuration(ENTER_ANIM_DURATION);
        expand.addListener(new AnimatorListener(this));
        RenderNodeAnimator slideX =
                new RenderNodeAnimator(props.getX(), mWidth / 2);
        RenderNodeAnimator slideY =
                new RenderNodeAnimator(props.getY(), mHeight / 2);
        expand.setTarget(canvas);
        slideX.setTarget(canvas);
        slideY.setTarget(canvas);
        startAnimation(expand, slideX, slideY);
    }

    private void startAnimation(Animator expand,
            Animator slideX, Animator slideY) {
        expand.setDuration(SLIDE_ANIM_DURATION);
        slideX.setDuration(SLIDE_ANIM_DURATION);
        slideY.setDuration(SLIDE_ANIM_DURATION);
        slideX.addListener(new AnimatorListener(this));
        expand.setInterpolator(LINEAR_INTERPOLATOR);
        slideX.setInterpolator(PATH_INTERPOLATOR);
        slideY.setInterpolator(PATH_INTERPOLATOR);
        expand.start();
        slideX.start();
        slideY.start();
        if (!mSparkle.isRunning()) {
            mSparkle.start();
            mActiveAnimations.add(mSparkle);
        }
        mActiveAnimations.add(expand);
        mActiveAnimations.add(slideX);
        mActiveAnimations.add(slideY);
    }

    private void enterSoftware() {
        ValueAnimator expand = ValueAnimator.ofFloat(0f, 0.5f);
        ValueAnimator slideX = ValueAnimator.ofFloat(
                mProperties.getX(), mWidth / 2);
        ValueAnimator slideY = ValueAnimator.ofFloat(
                mProperties.getY(), mHeight / 2);
        expand.addUpdateListener(updatedAnimation -> {
            notifyUpdate();
            mProperties.getShader().setProgress((Float) expand.getAnimatedValue());
        });
        expand.addListener(new AnimatorListener(this));
        expand.setInterpolator(LINEAR_INTERPOLATOR);
        expand.start();
        mActiveAnimations.add(expand);
        slideX.addUpdateListener(anim -> {
            float x = (float) slideX.getAnimatedValue();
            float y = (float) slideY.getAnimatedValue();
            mProperties.setOrigin(x, y);
            mProperties.getShader().setOrigin(x, y);
        });
        startAnimation(expand, slideX, slideY);
    }

    @NonNull AnimationProperties<Float, Paint> getProperties() {
        return mProperties;
    }

    @NonNull AnimationProperties getCanvasProperties() {
    @NonNull
    AnimationProperties<CanvasProperty<Float>, CanvasProperty<Paint>> getCanvasProperties() {
        if (mCanvasProperties == null) {
            mCanvasProperties = new AnimationProperties<>(
                    CanvasProperty.createFloat(mProperties.getX()),
@@ -209,6 +249,7 @@ public final class RippleAnimationSession {
        AnimatorListener(RippleAnimationSession session) {
            mSession = session;
        }

        @Override
        public void onAnimationStart(Animator animation) {

@@ -231,21 +272,12 @@ public final class RippleAnimationSession {
    }

    static class AnimationProperties<FloatType, PaintType> {
        private final FloatType mY;
        private FloatType mProgress;
        private FloatType mMaxRadius;
        private final FloatType mProgress;
        private final FloatType mMaxRadius;
        private final PaintType mPaint;
        private final FloatType mX;
        private final RippleShader mShader;
        private Runnable mOnChange;

        private void onChange() {
            if (mOnChange != null) mOnChange.run();
        }

        private void setOnChange(Runnable onChange) {
            mOnChange = onChange;
        }
        private FloatType mX;
        private FloatType mY;

        AnimationProperties(FloatType x, FloatType y, FloatType maxRadius,
                PaintType paint, FloatType progress, RippleShader shader) {
@@ -261,6 +293,11 @@ public final class RippleAnimationSession {
            return mProgress;
        }

        void setOrigin(FloatType x, FloatType y) {
            mX = x;
            mY = y;
        }

        FloatType getX() {
            return mX;
        }
+29 −29
Original line number Diff line number Diff line
@@ -48,6 +48,7 @@ import android.graphics.RecordingCanvas;
import android.graphics.Rect;
import android.graphics.Shader;
import android.os.Build;
import android.os.SystemProperties;
import android.util.AttributeSet;
import android.view.animation.LinearInterpolator;

@@ -159,6 +160,9 @@ public class RippleDrawable extends LayerDrawable {
    /** The maximum number of ripples supported. */
    private static final int MAX_RIPPLES = 10;
    private static final LinearInterpolator LINEAR_INTERPOLATOR = new LinearInterpolator();
    /** Temporary flag for teamfood. **/
    private static final boolean FORCE_PATTERNED_STYLE =
            SystemProperties.getBoolean("persist.material.patternedripple", false);

    private final Rect mTempRect = new Rect();

@@ -361,7 +365,9 @@ public class RippleDrawable extends LayerDrawable {
            }
        } else {
            if (focused || hovered) {
                if (!pressed) {
                    enterPatternedBackgroundAnimation(focused, hovered);
                }
            } else {
                exitPatternedBackgroundAnimation();
            }
@@ -571,7 +577,10 @@ public class RippleDrawable extends LayerDrawable {
        mState.mMaxRadius = a.getDimensionPixelSize(
                R.styleable.RippleDrawable_radius, mState.mMaxRadius);

        mState.mRippleStyle = a.getInteger(R.styleable.RippleDrawable_rippleStyle, STYLE_SOLID);
        if (!FORCE_PATTERNED_STYLE) {
            mState.mRippleStyle = a.getInteger(R.styleable.RippleDrawable_rippleStyle,
                    mState.mRippleStyle);
        }
    }

    private void verifyRequiredAttributes(@NonNull TypedArray a) throws XmlPullParserException {
@@ -812,21 +821,25 @@ public class RippleDrawable extends LayerDrawable {
    }

    private void drawPatterned(@NonNull Canvas canvas) {
        final Rect bounds = getBounds();
        final Rect bounds = getDirtyBounds();
        final int saveCount = canvas.save(Canvas.CLIP_SAVE_FLAG);
        boolean useCanvasProps = shouldUseCanvasProps(canvas);
        boolean changedHotspotBounds = !bounds.equals(mHotspotBounds);
        if (isBounded()) {
            canvas.clipRect(mHotspotBounds);
            canvas.clipRect(bounds);
        }
        float x, y;
        float x, y, w, h;
        if (changedHotspotBounds) {
            x = mHotspotBounds.exactCenterX();
            y = mHotspotBounds.exactCenterY();
            w = mHotspotBounds.width();
            h = mHotspotBounds.height();
            useCanvasProps = false;
        } else {
            x = mPendingX;
            y = mPendingY;
            w = bounds.width();
            h = bounds.height();
        }
        boolean shouldAnimate = mRippleActive;
        boolean shouldExit = mExitingAnimation;
@@ -837,9 +850,9 @@ public class RippleDrawable extends LayerDrawable {
        drawPatternedBackground(canvas);
        if (shouldAnimate && mRunningAnimations.size() <= MAX_RIPPLES) {
            RippleAnimationSession.AnimationProperties<Float, Paint> properties =
                    createAnimationProperties(x, y);
            mRunningAnimations.add(new RippleAnimationSession(properties, !useCanvasProps)
                    .setOnAnimationUpdated(useCanvasProps ? null : () -> invalidateSelf(false))
                    createAnimationProperties(x, y, w, h);
            mRunningAnimations.add(new RippleAnimationSession(properties, !useCanvasProps, w, h)
                    .setOnAnimationUpdated(() -> invalidateSelf(false))
                    .setOnSessionEnd(session -> {
                        mRunningAnimations.remove(session);
                    })
@@ -864,20 +877,8 @@ public class RippleDrawable extends LayerDrawable {
            } else {
                RippleAnimationSession.AnimationProperties<Float, Paint> p =
                        s.getProperties();
                float posX, posY;
                if (changedHotspotBounds) {
                    posX = x;
                    posY = y;
                    if (p.getPaint().getShader() instanceof RippleShader) {
                        RippleShader shader = (RippleShader) p.getPaint().getShader();
                        shader.setOrigin(posX, posY);
                    }
                } else {
                    posX = p.getX();
                    posY = p.getY();
                }
                float radius = p.getMaxRadius();
                canvas.drawCircle(posX, posY, radius, p.getPaint());
                canvas.drawCircle(p.getX(), p.getY(), radius, p.getPaint());
            }
        }
        canvas.restoreToCount(saveCount);
@@ -905,14 +906,13 @@ public class RippleDrawable extends LayerDrawable {

    private float computeRadius() {
        Rect b = getDirtyBounds();
        float gap = 0;
        float radius = (float) Math.sqrt(b.width() * b.width() + b.height() * b.height()) / 2 + gap;
        float radius = (float) Math.sqrt(b.width() * b.width() + b.height() * b.height()) / 2;
        return radius;
    }

    @NonNull
    private RippleAnimationSession.AnimationProperties<Float, Paint> createAnimationProperties(
            float x, float y) {
            float x, float y, float w, float h) {
        Paint p = new Paint(mRipplePaint);
        float radius = mState.mMaxRadius;
        RippleAnimationSession.AnimationProperties<Float, Paint> properties;
@@ -920,19 +920,19 @@ public class RippleDrawable extends LayerDrawable {
        int color = mMaskColorFilter == null
                ? mState.mColor.getColorForState(getState(), Color.BLACK)
                : mMaskColorFilter.getColor();
        color = color | 0xFF000000;
        shader.setColor(color);
        shader.setOrigin(x, y);
        shader.setResolution(w, h);
        shader.setSecondsOffset(0);
        shader.setRadius(radius);
        shader.setProgress(.0f);
        properties = new RippleAnimationSession.AnimationProperties<>(
                x, y, radius, p, 0f,
                shader);
        if (mMaskShader == null) {
            shader.setHasMask(false);
            shader.setShader(null);
        } else {
            shader.setShader(mMaskShader);
            shader.setHasMask(true);
        }
        p.setShader(shader);
        p.setColorFilter(null);
@@ -1160,7 +1160,7 @@ public class RippleDrawable extends LayerDrawable {
            // The ripple timing depends on the paint's alpha value, so we need
            // to push just the alpha channel into the paint and let the filter
            // handle the full-alpha color.
            int maskColor = color | 0xFF000000;
            int maskColor = mState.mRippleStyle == STYLE_PATTERNED ? color : color | 0xFF000000;
            if (mMaskColorFilter.getColor() != maskColor) {
                mMaskColorFilter = new PorterDuffColorFilter(maskColor, mMaskColorFilter.getMode());
            }
@@ -1276,7 +1276,7 @@ public class RippleDrawable extends LayerDrawable {
        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
        ColorStateList mColor = ColorStateList.valueOf(Color.MAGENTA);
        int mMaxRadius = RADIUS_AUTO;
        int mRippleStyle = STYLE_SOLID;
        int mRippleStyle = FORCE_PATTERNED_STYLE ? STYLE_PATTERNED : STYLE_SOLID;

        public RippleState(LayerState orig, RippleDrawable owner, Resources res) {
            super(orig, owner, res);
+135 −37
Original line number Diff line number Diff line
@@ -17,58 +17,153 @@
package android.graphics.drawable;

import android.annotation.ColorInt;
import android.annotation.NonNull;
import android.graphics.Color;
import android.graphics.RuntimeShader;
import android.graphics.Shader;

final class RippleShader extends RuntimeShader {
    private static final String SHADER = "uniform float2 in_origin;\n"
            + "uniform float in_maxRadius;\n"
    private static final String SHADER_UNIFORMS =  "uniform vec2 in_origin;\n"
            + "uniform float in_progress;\n"
            + "uniform float in_maxRadius;\n"
            + "uniform vec2 in_resolution;\n"
            + "uniform float in_hasMask;\n"
            + "uniform float4 in_color;\n"
            + "uniform shader in_shader;\n"
            + "float dist2(float2 p0, float2 pf) { return sqrt((pf.x - p0.x) * (pf.x - p0.x) + "
            + "(pf.y - p0.y) * (pf.y - p0.y)); }\n"
            + "float mod2(float a, float b) { return a - (b * floor(a / b)); }\n"
            + "float rand(float2 src) { return fract(sin(dot(src.xy, float2(12.9898, 78.233))) * "
            + "43758.5453123); }\n"
            + "float4 main(float2 p)\n"
            + "{\n"
            + "    float fraction = in_progress;\n"
            + "    float2 fragCoord = p;//sk_FragCoord.xy;\n"
            + "    float maxDist = in_maxRadius;\n"
            + "    float fragDist = dist2(in_origin, fragCoord.xy);\n"
            + "    float circleRadius = maxDist * fraction;\n"
            + "    float colorVal = (fragDist - circleRadius) / maxDist;\n"
            + "    float d = fragDist < circleRadius \n"
            + "        ? 1. - abs(colorVal * 3. * smoothstep(0., 1., fraction)) \n"
            + "        : 1. - abs(colorVal * 5.);\n"
            + "    d = smoothstep(0., 1., d);\n"
            + "    float divider = 2.;\n"
            + "    float x = floor(fragCoord.x / divider);\n"
            + "    float y = floor(fragCoord.y / divider);\n"
            + "    float density = .95;\n"
            + "    d = rand(float2(x, y)) > density ? d : d * .2;\n"
            + "    d = d * rand(float2(fraction, x * y));\n"
            + "    float alpha = 1. - pow(fraction, 2.);\n"
            + "    if (in_hasMask != 0.) {return sample(in_shader).a * in_color * d * alpha;}\n"
            + "    return in_color * d * alpha;\n"
            + "uniform float in_secondsOffset;\n"
            + "uniform vec4 in_color;\n"
            + "uniform shader in_shader;\n";
    private static final String SHADER_LIB =
            "float triangleNoise(vec2 n) {\n"
            + "    n  = fract(n * vec2(5.3987, 5.4421));\n"
            + "    n += dot(n.yx, n.xy + vec2(21.5351, 14.3137));\n"
            + "    float xy = n.x * n.y;\n"
            + "    return fract(xy * 95.4307) + fract(xy * 75.04961) - 1.0;\n"
            + "}"
            + "const float PI = 3.1415926535897932384626;\n"
            + "\n"
            + "float threshold(float v, float l, float h) {\n"
            + "  return step(l, v) * (1.0 - step(h, v));\n"
            + "}\n"
            + "\n"
            + "float sparkles(vec2 uv, float t) {\n"
            + "  float n = triangleNoise(uv);\n"
            + "  float s = 0.0;\n"
            + "  for (float i = 0; i < 4; i += 1) {\n"
            + "    float l = i * 0.25;\n"
            + "    float h = l + 0.025;\n"
            + "    float o = abs(sin(0.1 * PI * (t + i)));\n"
            + "    s += threshold(n + o, l, h);\n"
            + "  }\n"
            + "  return saturate(s);\n"
            + "}\n"
            + "\n"
            + "float softCircle(vec2 uv, vec2 xy, float radius, float blur) {\n"
            + "  float blurHalf = blur * 0.5;\n"
            + "  float d = distance(uv, xy);\n"
            + "  return 1. - smoothstep(1. - blurHalf, 1. + blurHalf, d / radius);\n"
            + "}\n"
            + "\n"
            + "float softRing(vec2 uv, vec2 xy, float radius, float blur) {\n"
            + "  float thickness = 0.4;\n"
            + "  float circle_outer = softCircle(uv, xy, radius + thickness * 0.5, blur);\n"
            + "  float circle_inner = softCircle(uv, xy, radius - thickness * 0.5, blur);\n"
            + "  return circle_outer - circle_inner;\n"
            + "}\n"
            + "\n"
            + "struct Viewport {\n"
            + "  float aspect;\n"
            + "  vec2 uv;\n"
            + "  vec2 resolution_pixels;\n"
            + "};\n"
            + "\n"
            + "Viewport getViewport(vec2 frag_coord, vec2 resolution_pixels) {\n"
            + "  Viewport v;\n"
            + "  v.aspect = resolution_pixels.y / resolution_pixels.x;\n"
            + "  v.uv = frag_coord / resolution_pixels;\n"
            + "  v.uv.y = (1.0 - v.uv.y) * v.aspect;\n"
            + "  v.resolution_pixels = resolution_pixels;\n"
            + "  return v;\n"
            + "}\n"
            + "\n"
            + "vec2 getTouch(vec2 touch_position_pixels, Viewport viewport) {\n"
            + "  vec2 touch = touch_position_pixels / viewport.resolution_pixels;\n"
            + "  touch.y *= viewport.aspect;\n"
            + "  return touch;\n"
            + "}\n"
            + "\n"
            + "struct Wave {\n"
            + "  float ring;\n"
            + "  float circle;\n"
            + "};\n"
            + "\n"
            + "Wave getWave(Viewport viewport, vec2 touch, float progress) {\n"
            + "  float fade = pow((clamp(progress, 0.8, 1.0)), 8.);\n"
            + "  Wave w;\n"
            + "  w.ring = max(softRing(viewport.uv, touch, progress, 0.45) - fade, 0.);\n"
            + "  w.circle = softCircle(viewport.uv, touch, 2.0 * progress, 0.2) - progress;\n"
            + "  return w;\n"
            + "}\n"
            + "\n"
            + "vec4 getRipple(vec4 color, float loudness, float sparkle, Wave wave) {\n"
            + "  float alpha = wave.ring * sparkle * loudness\n"
            + "        + wave.circle * color.a;\n"
            + "  return vec4(color.rgb, saturate(alpha));\n"
            + "}\n"
            + "\n"
            + "float getRingMask(vec2 frag, vec2 center, float r, float progress) {\n"
            + "      float dist = distance(frag, center);\n"
            + "      float expansion = r * .6;\n"
            + "      r = r * min(1.,progress);\n"
            + "      float minD = max(r - expansion, 0.);\n"
            + "      float maxD = r + expansion;\n"
            + "      if (dist > maxD || dist < minD) return .0;\n"
            + "      return min(maxD - dist, dist - minD) / expansion;    \n"
            + "}\n"
            + "\n"
            + "float subProgress(float start, float end, float progress) {\n"
            + "    float sub = clamp(progress, start, end);\n"
            + "    return (sub - start) / (end - start); \n"
            + "}\n";
    private static final String SHADER_MAIN = "vec4 main(vec2 p) {\n"
            + "    float fadeIn = subProgress(0., 0.175, in_progress);\n"
            + "    float fadeOutNoise = subProgress(0.375, 1., in_progress);\n"
            + "    float fadeOutRipple = subProgress(0.375, 0.75, in_progress);\n"
            + "    Viewport vp = getViewport(p, in_resolution);\n"
            + "    vec2 touch = getTouch(in_origin, vp);\n"
            + "    Wave w = getWave(vp, touch, in_progress * 0.25);\n"
            + "    float ring = getRingMask(p, in_origin, in_maxRadius, fadeIn);\n"
            + "    float alpha = min(fadeIn, 1. - fadeOutNoise);\n"
            + "    float sparkle = sparkles(p, in_progress * 0.25 + in_secondsOffset)\n"
            + "        * ring * alpha;\n"
            + "    vec4 r = getRipple(in_color, 1., sparkle, w);\n"
            + "    float fade = min(fadeIn, 1.-fadeOutRipple);\n"
            + "    vec4 circle = vec4(in_color.rgb, softCircle(p, in_origin, in_maxRadius "
            + "      * fadeIn, 0.2) * fade * in_color.a);\n"
            + "    float mask = in_hasMask == 1. ? sample(in_shader).a > 0. ? 1. : 0. : 1.;\n"
            + "    return mix(circle, vec4(1.), sparkle * mask);\n"
            + "}";
    private static final String SHADER = SHADER_UNIFORMS + SHADER_LIB + SHADER_MAIN;

    RippleShader() {
        super(SHADER, false);
    }

    public void setShader(@NonNull Shader s) {
        setInputShader("in_shader", s);
    public void setShader(Shader shader) {
        if (shader != null) {
            setInputShader("in_shader", shader);
        }
        setUniform("in_hasMask", shader == null ? 0 : 1);
    }

    public void setRadius(float radius) {
        setUniform("in_maxRadius", radius);
    }

    /**
     * Continuous offset used as noise phase.
     */
    public void setSecondsOffset(float t) {
        setUniform("in_secondsOffset", t);
    }

    public void setOrigin(float x, float y) {
        setUniform("in_origin", new float[] {x, y});
    }
@@ -77,13 +172,16 @@ final class RippleShader extends RuntimeShader {
        setUniform("in_progress", progress);
    }

    public void setHasMask(boolean hasMask) {
        setUniform("in_hasMask", hasMask ? 1 : 0);
    }

    /**
     * Color of the circle that's under the sparkles. Sparkles will always be white.
     */
    public void setColor(@ColorInt int colorIn) {
        Color color = Color.valueOf(colorIn);
        this.setUniform("in_color", new float[] {color.red(),
                color.green(), color.blue(), color.alpha()});
    }

    public void setResolution(float w, float h) {
        setUniform("in_resolution", w, h);
    }
}