/* * Copyright (C) 2014 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.recents.views; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Outline; import android.graphics.Paint; import android.graphics.Rect; import android.util.AttributeSet; import android.view.View; import android.view.ViewPropertyAnimator; import android.view.animation.AccelerateInterpolator; import android.widget.FrameLayout; import com.android.systemui.R; import com.android.systemui.recents.misc.Console; import com.android.systemui.recents.Constants; import com.android.systemui.recents.RecentsConfiguration; import com.android.systemui.recents.model.Task; import com.android.systemui.recents.model.TaskStack; /* A task view */ public class TaskView extends FrameLayout implements Task.TaskCallbacks, View.OnClickListener, View.OnLongClickListener { /** The TaskView callbacks */ interface TaskViewCallbacks { public void onTaskViewAppIconClicked(TaskView tv); public void onTaskViewAppInfoClicked(TaskView tv); public void onTaskViewClicked(TaskView tv, Task t, boolean lockToTask); public void onTaskViewDismissed(TaskView tv); } RecentsConfiguration mConfig; int mFooterHeight; int mMaxFooterHeight; ObjectAnimator mFooterAnimator; int mDim; int mMaxDim; AccelerateInterpolator mDimInterpolator = new AccelerateInterpolator(); Task mTask; boolean mTaskDataLoaded; boolean mIsFocused; boolean mIsStub; boolean mClipViewInStack; Rect mTmpRect = new Rect(); Paint mLayerPaint = new Paint(); Outline mOutline = new Outline(); TaskThumbnailView mThumbnailView; TaskBarView mBarView; View mLockToAppButtonView; TaskViewCallbacks mCb; // Optimizations ValueAnimator.AnimatorUpdateListener mUpdateDimListener = new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { updateDimOverlayFromScale(); } }; Runnable mEnableThumbnailClip = new Runnable() { @Override public void run() { mThumbnailView.updateTaskBarClip(mBarView); } }; Runnable mDisableThumbnailClip = new Runnable() { @Override public void run() { mThumbnailView.disableClipTaskBarView(); } }; public TaskView(Context context) { this(context, null); } public TaskView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public TaskView(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public TaskView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); mConfig = RecentsConfiguration.getInstance(); mMaxFooterHeight = mConfig.taskViewLockToAppButtonHeight; setWillNotDraw(false); setClipToOutline(true); setDim(getDim()); setFooterHeight(getFooterHeight()); } @Override protected void onFinishInflate() { mMaxDim = mConfig.taskStackMaxDim; // By default, all views are clipped to other views in their stack mClipViewInStack = true; // Bind the views mBarView = (TaskBarView) findViewById(R.id.task_view_bar); mThumbnailView = (TaskThumbnailView) findViewById(R.id.task_view_thumbnail); mLockToAppButtonView = findViewById(R.id.lock_to_app); if (mTaskDataLoaded) { onTaskDataLoaded(); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int width = MeasureSpec.getSize(widthMeasureSpec); int height = MeasureSpec.getSize(heightMeasureSpec); // Measure the bar view, thumbnail, and lock-to-app buttons mBarView.measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(mConfig.taskBarHeight, MeasureSpec.EXACTLY)); mLockToAppButtonView.measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(mConfig.taskViewLockToAppButtonHeight, MeasureSpec.EXACTLY)); // Measure the thumbnail height to be the same as the width mThumbnailView.measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)); setMeasuredDimension(width, height); updateOutline(); } /** Updates the outline to match whether the lock-to-app button is visible or not. */ void updateOutline() { int height = getMeasuredHeight(); if (height == 0) return; // Account for the current footer height height = height - mMaxFooterHeight + mFooterHeight; mOutline.setRoundRect(0, 0, getMeasuredWidth(), height, mConfig.taskViewRoundedCornerRadiusPx); setOutline(mOutline); } /** Set callback */ void setCallbacks(TaskViewCallbacks cb) { mCb = cb; } /** Gets the task */ Task getTask() { return mTask; } /** Synchronizes this view's properties with the task's transform */ void updateViewPropertiesToTaskTransform(TaskViewTransform toTransform, int duration) { if (Console.Enabled) { Console.log(Constants.Log.UI.Draw, "[TaskView|updateViewPropertiesToTaskTransform]", "duration: " + duration, Console.AnsiPurple); } // Update the bar view mBarView.updateViewPropertiesToTaskTransform(toTransform, duration); // Check to see if any properties have changed, and update the task view if (duration > 0) { ViewPropertyAnimator anim = animate(); boolean useLayers = false; // Animate to the final state if (toTransform.hasTranslationYChangedFrom(getTranslationY())) { anim.translationY(toTransform.translationY); } if (Constants.DebugFlags.App.EnableShadows && toTransform.hasTranslationZChangedFrom(getTranslationZ())) { anim.translationZ(toTransform.translationZ); } if (toTransform.hasScaleChangedFrom(getScaleX())) { anim.scaleX(toTransform.scale) .scaleY(toTransform.scale) .setUpdateListener(mUpdateDimListener); useLayers = true; } if (toTransform.hasAlphaChangedFrom(getAlpha())) { // Use layers if we animate alpha anim.alpha(toTransform.alpha); useLayers = true; } if (useLayers) { anim.withLayer(); } anim.setStartDelay(toTransform.startDelay) .setDuration(duration) .setInterpolator(mConfig.fastOutSlowInInterpolator) .start(); } else { // Set the changed properties if (toTransform.hasTranslationYChangedFrom(getTranslationY())) { setTranslationY(toTransform.translationY); } if (Constants.DebugFlags.App.EnableShadows && toTransform.hasTranslationZChangedFrom(getTranslationZ())) { setTranslationZ(toTransform.translationZ); } if (toTransform.hasScaleChangedFrom(getScaleX())) { setScaleX(toTransform.scale); setScaleY(toTransform.scale); updateDimOverlayFromScale(); } if (toTransform.hasAlphaChangedFrom(getAlpha())) { setAlpha(toTransform.alpha); } } } /** Resets this view's properties */ void resetViewProperties() { setTranslationX(0f); setTranslationY(0f); if (Constants.DebugFlags.App.EnableShadows) { setTranslationZ(0f); } setScaleX(1f); setScaleY(1f); setAlpha(1f); setDim(0); invalidate(); } /** * When we are un/filtering, this method will set up the transform that we are animating to, * in order to hide the task. */ void prepareTaskTransformForFilterTaskHidden(TaskViewTransform toTransform) { // Fade the view out and slide it away toTransform.alpha = 0f; toTransform.translationY += 200; toTransform.translationZ = 0; } /** * When we are un/filtering, this method will setup the transform that we are animating from, * in order to show the task. */ void prepareTaskTransformForFilterTaskVisible(TaskViewTransform fromTransform) { // Fade the view in fromTransform.alpha = 0f; } /** Prepares this task view for the enter-recents animations. This is called earlier in the * first layout because the actual animation into recents may take a long time. */ public void prepareEnterRecentsAnimation(boolean isTaskViewLaunchTargetTask, int offsetY, int offscreenY) { if (mConfig.launchedFromAppWithScreenshot) { if (isTaskViewLaunchTargetTask) { // Hide the task view as we are going to animate the full screenshot into view // and then replace it with this view once we are done setVisibility(View.INVISIBLE); // Also hide the front most task bar view so we can animate it in mBarView.prepareEnterRecentsAnimation(); } else { // Top align the task views setTranslationY(offsetY); setScaleX(1f); setScaleY(1f); } } else if (mConfig.launchedFromAppWithThumbnail) { if (isTaskViewLaunchTargetTask) { // Hide the front most task bar view so we can animate it in mBarView.prepareEnterRecentsAnimation(); // Set the dim to 0 so we can animate it in setDim(0); } } else if (mConfig.launchedFromHome) { // Move the task view off screen (below) so we can animate it in setTranslationY(offscreenY); if (Constants.DebugFlags.App.EnableShadows) { setTranslationZ(0); } setScaleX(1f); setScaleY(1f); } } /** Animates this task view as it enters recents */ public void startEnterRecentsAnimation(final ViewAnimation.TaskViewEnterContext ctx) { TaskViewTransform transform = ctx.currentTaskTransform; if (mConfig.launchedFromAppWithScreenshot) { if (ctx.isCurrentTaskLaunchTarget) { // Animate the full screenshot down first, before swapping with this task view ctx.fullScreenshotView.animateOnEnterRecents(ctx, new Runnable() { @Override public void run() { // Animate the task bar of the first task view mBarView.startEnterRecentsAnimation(0, mEnableThumbnailClip); setVisibility(View.VISIBLE); // Animate the footer into view animateFooterVisibility(true, mConfig.taskBarEnterAnimDuration, 0); // Decrement the post animation trigger ctx.postAnimationTrigger.decrement(); } }); } else { // Animate the tasks down behind the full screenshot animate() .scaleX(transform.scale) .scaleY(transform.scale) .translationY(transform.translationY) .setStartDelay(0) .setUpdateListener(null) .setInterpolator(mConfig.linearOutSlowInInterpolator) .setDuration(475) .withLayer() .withEndAction(new Runnable() { @Override public void run() { mEnableThumbnailClip.run(); // Decrement the post animation trigger ctx.postAnimationTrigger.decrement(); } }) .start(); } ctx.postAnimationTrigger.increment(); } else if (mConfig.launchedFromAppWithThumbnail) { if (ctx.isCurrentTaskLaunchTarget) { // Animate the task bar of the first task view mBarView.startEnterRecentsAnimation(mConfig.taskBarEnterAnimDelay, mEnableThumbnailClip); // Animate the dim into view as well ObjectAnimator anim = ObjectAnimator.ofInt(this, "dim", getDimOverlayFromScale()); anim.setStartDelay(mConfig.taskBarEnterAnimDelay); anim.setDuration(mConfig.taskBarEnterAnimDuration); anim.setInterpolator(mConfig.fastOutLinearInInterpolator); anim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { // Decrement the post animation trigger ctx.postAnimationTrigger.decrement(); } }); anim.start(); ctx.postAnimationTrigger.increment(); // Animate the footer into view animateFooterVisibility(true, mConfig.taskBarEnterAnimDuration, mConfig.taskBarEnterAnimDelay); } else { mEnableThumbnailClip.run(); } } else if (mConfig.launchedFromHome) { // Animate the tasks up int frontIndex = (ctx.currentStackViewCount - ctx.currentStackViewIndex - 1); int delay = mConfig.taskBarEnterAnimDelay + frontIndex * mConfig.taskViewEnterFromHomeDelay; if (Constants.DebugFlags.App.EnableShadows) { animate().translationZ(transform.translationZ); } animate() .scaleX(transform.scale) .scaleY(transform.scale) .translationY(transform.translationY) .setStartDelay(delay) .setUpdateListener(null) .setInterpolator(mConfig.quintOutInterpolator) .setDuration(mConfig.taskViewEnterFromHomeDuration) .withLayer() .withEndAction(new Runnable() { @Override public void run() { mEnableThumbnailClip.run(); // Decrement the post animation trigger ctx.postAnimationTrigger.decrement(); } }) .start(); ctx.postAnimationTrigger.increment(); // Animate the footer into view animateFooterVisibility(true, mConfig.taskViewEnterFromHomeDuration, mConfig.taskBarEnterAnimDelay); } else { // Otherwise, just enable the thumbnail clip mEnableThumbnailClip.run(); // Animate the footer into view animateFooterVisibility(true, 0, 0); } } /** Animates this task view as it leaves recents by pressing home. */ public void startExitToHomeAnimation(ViewAnimation.TaskViewExitContext ctx) { animate() .translationY(ctx.offscreenTranslationY) .setStartDelay(0) .setUpdateListener(null) .setInterpolator(mConfig.fastOutLinearInInterpolator) .setDuration(mConfig.taskViewExitToHomeDuration) .withLayer() .withEndAction(ctx.postAnimationTrigger.decrementAsRunnable()) .start(); ctx.postAnimationTrigger.increment(); } /** Animates this task view as it exits recents */ public void startLaunchTaskAnimation(final Runnable r, boolean isLaunchingTask) { if (isLaunchingTask) { // Disable the thumbnail clip and animate the bar out mBarView.startLaunchTaskAnimation(mDisableThumbnailClip, r); // Animate the dim if (mDim > 0) { ObjectAnimator anim = ObjectAnimator.ofInt(this, "dim", 0); anim.setDuration(mConfig.taskBarExitAnimDuration); anim.setInterpolator(mConfig.fastOutLinearInInterpolator); anim.start(); } } else { // Hide the dismiss button mBarView.startLaunchTaskDismissAnimation(); } } /** Animates the deletion of this task view */ public void startDeleteTaskAnimation(final Runnable r) { // Disabling clipping with the stack while the view is animating away setClipViewInStack(false); animate().translationX(mConfig.taskViewRemoveAnimTranslationXPx) .alpha(0f) .setStartDelay(0) .setUpdateListener(null) .setInterpolator(mConfig.fastOutSlowInInterpolator) .setDuration(mConfig.taskViewRemoveAnimDuration) .withLayer() .withEndAction(new Runnable() { @Override public void run() { // We just throw this into a runnable because starting a view property // animation using layers can cause inconsisten results if we try and // update the layers while the animation is running. In some cases, // the runnabled passed in may start an animation which also uses layers // so we defer all this by posting this. r.run(); // Re-enable clipping with the stack (we will reuse this view) setClipViewInStack(true); } }) .start(); } /** Animates this task view if the user does not interact with the stack after a certain time. */ public void startNoUserInteractionAnimation() { mBarView.startNoUserInteractionAnimation(); } /** Mark this task view that the user does has not interacted with the stack after a certain time. */ public void setNoUserInteractionState() { mBarView.setNoUserInteractionState(); } /** Returns the rect we want to clip (it may not be the full rect) */ Rect getClippingRect(Rect outRect) { getHitRect(outRect); // XXX: We should get the hit rect of the thumbnail view and intersect, but this is faster outRect.right = outRect.left + mThumbnailView.getRight(); outRect.bottom = outRect.top + mThumbnailView.getBottom(); return outRect; } /** Enable the hw layers on this task view */ void enableHwLayers() { mThumbnailView.setLayerType(View.LAYER_TYPE_HARDWARE, mLayerPaint); mBarView.enableHwLayers(); mLockToAppButtonView.setLayerType(View.LAYER_TYPE_HARDWARE, mLayerPaint); } /** Disable the hw layers on this task view */ void disableHwLayers() { mThumbnailView.setLayerType(View.LAYER_TYPE_NONE, mLayerPaint); mBarView.disableHwLayers(); mLockToAppButtonView.setLayerType(View.LAYER_TYPE_NONE, mLayerPaint); } /** Sets the stubbed state of this task view. */ void setStubState(boolean isStub) { if (!mIsStub && isStub) { // This is now a stub task view, so clip to the bar height, hide the thumbnail setClipBounds(new Rect(0, 0, getMeasuredWidth(), mBarView.getMeasuredHeight())); mThumbnailView.setVisibility(View.INVISIBLE); // Temporary mBarView.mActivityDescription.setText("Stub"); } else if (mIsStub && !isStub) { setClipBounds(null); mThumbnailView.setVisibility(View.VISIBLE); } mIsStub = isStub; } /** * Returns whether this view should be clipped, or any views below should clip against this * view. */ boolean shouldClipViewInStack() { return mClipViewInStack && (getVisibility() == View.VISIBLE); } /** Sets whether this view should be clipped, or clipped against. */ void setClipViewInStack(boolean clip) { if (clip != mClipViewInStack) { mClipViewInStack = clip; if (getParent() instanceof View) { getHitRect(mTmpRect); ((View) getParent()).invalidate(mTmpRect); } } } /** Sets the footer height. */ public void setFooterHeight(int height) { mFooterHeight = height; updateOutline(); invalidate(0, getMeasuredHeight() - mMaxFooterHeight, getMeasuredWidth(), getMeasuredHeight()); } /** Gets the footer height. */ public int getFooterHeight() { return mFooterHeight; } /** Animates the footer into and out of view. */ public void animateFooterVisibility(boolean visible, int duration, int delay) { if (!mTask.canLockToTask) return; if (mMaxFooterHeight <= 0) return; if (mFooterAnimator != null) { mFooterAnimator.removeAllListeners(); mFooterAnimator.cancel(); } int height = visible ? mMaxFooterHeight : 0; if (visible && mLockToAppButtonView.getVisibility() != View.VISIBLE) { if (duration > 0) { setFooterHeight(0); } else { setFooterHeight(mMaxFooterHeight); } mLockToAppButtonView.setVisibility(View.VISIBLE); } if (duration > 0) { mFooterAnimator = ObjectAnimator.ofInt(this, "footerHeight", height); mFooterAnimator.setDuration(duration); mFooterAnimator.setInterpolator(mConfig.fastOutSlowInInterpolator); if (!visible) { mFooterAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { mLockToAppButtonView.setVisibility(View.INVISIBLE); } }); } mFooterAnimator.start(); } else { if (!visible) { mLockToAppButtonView.setVisibility(View.INVISIBLE); } } } /** Returns the current dim. */ public void setDim(int dim) { mDim = dim; postInvalidateOnAnimation(); } /** Returns the current dim. */ public int getDim() { return mDim; } /** Compute the dim as a function of the scale of this view. */ int getDimOverlayFromScale() { float minScale = TaskStackViewLayoutAlgorithm.StackPeekMinScale; float scaleRange = 1f - minScale; float dim = (1f - getScaleX()) / scaleRange; dim = mDimInterpolator.getInterpolation(Math.min(dim, 1f)); return Math.max(0, Math.min(mMaxDim, (int) (dim * 255))); } /** Update the dim as a function of the scale of this view. */ void updateDimOverlayFromScale() { setDim(getDimOverlayFromScale()); } @Override public void draw(Canvas canvas) { super.draw(canvas); // Apply the dim if necessary if (mDim > 0) { canvas.drawColor(mDim << 24); } } @Override protected boolean drawChild(Canvas canvas, View child, long drawingTime) { if (mIsStub && (child == mThumbnailView)) { // Skip the thumbnail view if we are in stub mode return false; } return super.drawChild(canvas, child, drawingTime); } /** * Sets the focused task explicitly. We need a separate flag because requestFocus() won't happen * if the view is not currently visible, or we are in touch state (where we still want to keep * track of focus). */ public void setFocusedTask() { mIsFocused = true; // Workaround, we don't always want it focusable in touch mode, but we want the first task // to be focused after the enter-recents animation, which can be triggered from either touch // or keyboard setFocusableInTouchMode(true); requestFocus(); setFocusableInTouchMode(false); invalidate(); } /** * Updates the explicitly focused state when the view focus changes. */ @Override protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); if (!gainFocus) { mIsFocused = false; invalidate(); } } /** * Returns whether we have explicitly been focused. */ public boolean isFocusedTask() { return mIsFocused || isFocused(); } /**** TaskCallbacks Implementation ****/ /** Binds this task view to the task */ public void onTaskBound(Task t) { mTask = t; mTask.setCallbacks(this); if (getMeasuredWidth() == 0) { animateFooterVisibility(t.canLockToTask, 0, 0); } else { animateFooterVisibility(t.canLockToTask, mConfig.taskViewLockToAppLongAnimDuration, 0); } } @Override public void onTaskDataLoaded() { if (mThumbnailView != null && mBarView != null) { // Bind each of the views to the new task data mThumbnailView.rebindToTask(mTask); mBarView.rebindToTask(mTask); // Rebind any listeners if (Constants.DebugFlags.App.EnableTaskFiltering) { mBarView.mApplicationIcon.setOnClickListener(this); } mBarView.mDismissButton.setOnClickListener(this); mLockToAppButtonView.setOnClickListener(this); if (Constants.DebugFlags.App.EnableDevAppInfoOnLongPress) { if (mConfig.developerOptionsEnabled) { mBarView.mApplicationIcon.setOnLongClickListener(this); } } } mTaskDataLoaded = true; } @Override public void onTaskDataUnloaded() { if (mThumbnailView != null && mBarView != null) { // Unbind each of the views from the task data and remove the task callback mTask.setCallbacks(null); mThumbnailView.unbindFromTask(); mBarView.unbindFromTask(); // Unbind any listeners if (Constants.DebugFlags.App.EnableTaskFiltering) { mBarView.mApplicationIcon.setOnClickListener(null); } mBarView.mDismissButton.setOnClickListener(null); mLockToAppButtonView.setOnClickListener(null); if (Constants.DebugFlags.App.EnableDevAppInfoOnLongPress) { mBarView.mApplicationIcon.setOnLongClickListener(null); } } mTaskDataLoaded = false; } /** Enables/disables handling touch on this task view. */ void setTouchEnabled(boolean enabled) { setOnClickListener(enabled ? this : null); } @Override public void onClick(final View v) { // We purposely post the handler delayed to allow for the touch feedback to draw final TaskView tv = this; postDelayed(new Runnable() { @Override public void run() { if (v == mBarView.mApplicationIcon) { mCb.onTaskViewAppIconClicked(tv); } else if (v == mBarView.mDismissButton) { // Animate out the view and call the callback startDeleteTaskAnimation(new Runnable() { @Override public void run() { mCb.onTaskViewDismissed(tv); } }); // Hide the footer tv.animateFooterVisibility(false, mConfig.taskViewRemoveAnimDuration, 0); } else if (v == tv || v == mLockToAppButtonView) { mCb.onTaskViewClicked(tv, tv.getTask(), (v == mLockToAppButtonView)); } } }, 125); } @Override public boolean onLongClick(View v) { if (v == mBarView.mApplicationIcon) { mCb.onTaskViewAppInfoClicked(this); return true; } return false; } }