Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Android skips onDraw() when I run my animation in reverse

I have an implementation of the Sliding Fragments DevByte. In addition to sliding the fragment into view, I want to draw a shadow over the content that it's occluding. I have modified the FractionalLinearLayout from the video to measure itself at twice the screen width and layout its children in the right half. During the animation loop, I draw a black rectangle of increasing alpha in the left half and set my X-coordinate to a negative value to bring the right half into view.

My problem is that this works fine when I'm sliding the content into view, but fails when I'm sliding the content back out of view. On the way in, I get exactly the behaviour I want: a translation and a darkening shadow. On the way out, I get only the translation, and the shadow remains just its first frame color all the way through the animation.

What I can see in my logging is that on the way in, the setPercentOnScreen() and onDraw() methods are called alternatingly, just as expected. On the way out however, I get one call to setPercentageOnScreen(), followed by one call to onDraw(), followed by only calls to setPercentOnScreen(). Android is optimizing away the drawing, but I can't figure out why.

updated: What's interesting is that I only see this behaviour on an emulator running Android 4.4. Emulators running 4.0.3 and 4.3 run the animation as intended in both directions. An old Nexus 7 exhibits the problem, a different emulator 4.4 does not. It appears to be consistent on a device, but vary between devices.

updated again: I've extracted a sample project and put it on GitHub: barend/android-slidingfragment. The readme file on GitHub contains test results on a dozen devices. For emulators, the problem correlates to the "Enable Host GPU" feature, but only on Jelly Bean and KitKat; not on ICS.

updated yet again: further testing shows that the problem occurs on physical devices running Jelly Bean and higher, as well as on ARM emulators with "Use Host GPU" enabled running Jelly Bean or higher. It does not occur on x86 emulators and on ARM emulators without "Use Host GPU", regardless of Android version. The exact table of my tests can be found in the github project linked above.

// Imports left out
public class HorizontalSlidingLayout extends FrameLayout {
    /**
     * The fraction by which the content has slid into view. Legal range: from 0.0 (all content
     * off-screen) to 1.0 (all content visible).
     */
    private float percentOnScreen;
    private int screenWidth, screenHeight, shift;
    private Paint shadowPaint;

    // Constructors left out, all three call super, then init().

    private void init() {
        if (isInEditMode()) {
            // Ensure content is visible in edit mode.
            percentOnScreen = 1.0f;
        } else {
            setWillNotDraw(false);
            percentOnScreen = 0.0f;
            shadowPaint = new Paint();
            shadowPaint.setAlpha(0x00);
            shadowPaint.setColor(0x000000);
            shadowPaint.setStyle(Paint.Style.FILL);
        }
    }

    /** Reports our own size as (2w, h) and measures all children at (w, h). */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        screenWidth = MeasureSpec.getSize(widthMeasureSpec);
        screenHeight = MeasureSpec.getSize(heightMeasureSpec);
        setMeasuredDimension(2 * screenWidth, screenHeight);
        for (int i = 0, max = getChildCount(); i < max; i++) {
            View child = getChildAt(i);
            child.measure(widthMeasureSpec, heightMeasureSpec);
        }
    }

    /** Lays out the children in the right half of the view. */
    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        for (int i = 0, max = getChildCount(); i < max; i++) {
            View child = getChildAt(i);
            child.layout(screenWidth, top, right, bottom);
        }
    }

    /**
     * Draws a translucent shadow in the left half of the view, darkening by
     * {@code percentOnScreen}, then lets superclass draw children in the right half.
     */
    @Override
    protected void onDraw(Canvas canvas) {
        // Maintain 30% translucency
        if (percentOnScreen < 0.7f) {
            shadowPaint.setAlpha((int) (percentOnScreen * 0xFF));
        }
        android.util.Log.i("Slider", "onDraw(" + percentOnScreen + ") -> alpha(" + shadowPaint.getAlpha() + ')');
        canvas.drawRect(shift, 0, screenWidth, screenHeight, shadowPaint);
        super.onDraw(canvas);
    }

    @SuppressWarnings("unused")
    public float getPercentOnScreen() {
        return percentOnScreen;
    }

    /** Repeatedly invoked by an Animator. */
    @SuppressWarnings("unused")
    public void setPercentOnScreen(float fraction) {
        this.percentOnScreen = fraction;
        shift = (int)(fraction < 1.0 ? fraction * screenWidth : screenWidth);
        setX(-shift);
        android.util.Log.i("Slider", "setPOS(" + fraction + ") -> invalidate(" + shift + ',' + screenWidth + ')');
        invalidate(shift, 0, screenWidth, screenHeight);
        //invalidate() // Makes no difference
    }
}

What's weird is that this violates symmetry. I'm doing the exact same thing in reverse when sliding out as I do when sliding in, but the behaviour is different. I'm probably overlooking something stupid. Any ideas?

like image 823
Barend Avatar asked Nov 02 '13 13:11

Barend


1 Answers

It's a bug in the invalidation/redrawing logic for hardware-accelerated views in Android. I would expect to see the same bug in JB by default, but only on ICS if you opt into hardware acceleration (hw accel is enabled by default as of JB).

The problem is that when views are removed from the hierarchy and then animated out (such as what happens in the fragment transaction in your app, or in LayoutTransition when a removed view is faded out, or in an AlphaAnimation that fades out a removed view), they do not participate in the same invalidation/redrawing logic that normal/parented child views do. They are redisplayed correctly (thus we see the fragment slide out), but they are not redrawn, so that if their contents actually change during this period, they will not be redisplayed with those changes. The effect of the bug in your app is that the fragment slides out correctly, but the shadow is not drawn because that requires the view to be redrawn to pick up those changes.

The way that you are invalidating the view is correct, but the bug means that the invalidation has no effect.

The bug hasn't appeared before because, I believe, it's not common for disappearing views to change their appearance as they are being animated out (they usually just slide or fade out, which works fine).

The bug should be fixed in a future release (I have a fix for it already). Meanwhile, a workaround for your particular situation is to add an invalidation of the parent container as well; this will force the view to be redrawn and to correctly display the shadow:

if (getParent() instanceof ViewGroup) {
    ((ViewGroup) getParent()).invalidate();
}
like image 74
Chet Haase Avatar answered Oct 28 '22 06:10

Chet Haase