DropAffordanceHighlighter.java
/*
* Copyright 2021 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 androidx.draganddrop;
import static android.view.DragEvent.ACTION_DRAG_ENDED;
import static android.view.DragEvent.ACTION_DRAG_ENTERED;
import static android.view.DragEvent.ACTION_DRAG_EXITED;
import static android.view.DragEvent.ACTION_DRAG_STARTED;
import static java.lang.Math.max;
import static java.lang.Math.round;
import android.annotation.SuppressLint;
import android.content.ClipDescription;
import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.TypedArray;
import android.graphics.BlendMode;
import android.graphics.PorterDuff.Mode;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.os.Build;
import android.view.DragEvent;
import android.view.Gravity;
import android.view.View;
import androidx.annotation.ColorInt;
import androidx.annotation.DoNotInline;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.util.Preconditions;
import androidx.core.util.Predicate;
import java.util.HashSet;
import java.util.Set;
/** Used for visually indicating a View's affordance as a drop target. */
final class DropAffordanceHighlighter {
static final float FILL_OPACITY_INACTIVE = .2f;
static final float FILL_OPACITY_ACTIVE = .65f;
private static final float STROKE_OPACITY_INACTIVE = .4f;
private static final int STROKE_OPACITY_ACTIVE = 1;
private static final int STROKE_WIDTH_DP = 3;
static final int DEFAULT_CORNER_RADIUS_DP = 16;
private static final @ColorInt int DEFAULT_COLOR = 0xFF009688;
private static final int DEFAULT_GRAVITY = Gravity.FILL;
private boolean mDragInProgress = false;
final View mViewToHighlight;
private final Predicate<ClipDescription> mEligibilityPredicate;
private final boolean mAcceptDragsWithLocalState;
/** Highlight for possible targets (light) */
private final Drawable mHighlightAffordance;
/** Highlight for the current target that will receive the content if the user lets go (dark) */
private final Drawable mSelectedAffordance;
private final Set<View> mViewsWithDragFocus = new HashSet<>();
@Nullable
private Drawable mOriginalForeground;
private int mOriginalForegroundGravity = DEFAULT_GRAVITY;
@Nullable
BlendMode mOriginalForegroundTintBlendMode;
@Nullable
private ColorStateList mOriginalForegroundTintList;
@Nullable
private Mode mOriginalForegroundTintMode;
DropAffordanceHighlighter(
View viewToHighlight,
Predicate<ClipDescription> eligibilityPredicate,
boolean acceptDragsWithLocalState,
@ColorInt int highlightColor,
int cornerRadiusPx) {
this.mViewToHighlight = viewToHighlight;
this.mEligibilityPredicate = eligibilityPredicate;
this.mAcceptDragsWithLocalState = acceptDragsWithLocalState;
@ColorInt int inactiveColor = colorWithOpacity(
highlightColor, FILL_OPACITY_INACTIVE);
@ColorInt int activeColor = colorWithOpacity(
highlightColor, FILL_OPACITY_ACTIVE);
@ColorInt int inactiveStrokeColor = colorWithOpacity(
highlightColor, STROKE_OPACITY_INACTIVE);
@ColorInt int activeStrokeColor = colorWithOpacity(
highlightColor, STROKE_OPACITY_ACTIVE);
this.mHighlightAffordance = DropAffordanceHighlighter.makeDrawable(
mViewToHighlight.getContext(), inactiveColor, inactiveStrokeColor, cornerRadiusPx);
this.mSelectedAffordance = DropAffordanceHighlighter.makeDrawable(
mViewToHighlight.getContext(), activeColor, activeStrokeColor, cornerRadiusPx);
}
/** Makes a new builder for highlighting the given view. */
static @NonNull DropAffordanceHighlighter.Builder forView(
@NonNull View viewToHighlight,
@NonNull Predicate<ClipDescription> eligibilityPredicate) {
Preconditions.checkNotNull(viewToHighlight);
Preconditions.checkNotNull(eligibilityPredicate);
return new DropAffordanceHighlighter.Builder(viewToHighlight, eligibilityPredicate);
}
/** Sets the highlight state based on the drag events. */
boolean onDrag(@NonNull View reportingView, @NonNull DragEvent dragEvent) {
if (!mAcceptDragsWithLocalState && dragEvent.getLocalState() != null) {
return false;
}
int action = dragEvent.getAction();
// ClipDescription is not present in ACTION_DRAG_ENDED.
if (action != ACTION_DRAG_ENDED
&& !mEligibilityPredicate.test(dragEvent.getClipDescription())) {
return false;
}
handleDragEvent(reportingView, action);
// Return true on DRAG_STARTED, so we continue to receive events.
// @see https://developer.android.com/reference/android/view/DragEvent#ACTION_DRAG_STARTED
return action == ACTION_DRAG_STARTED;
}
private static @ColorInt int colorWithOpacity(@ColorInt int color, float opacity) {
return (0x00ffffff & color) | (((int) (255 * opacity)) << 24);
}
private void handleDragEvent(View reportingView, int action) {
switch (action) {
case ACTION_DRAG_STARTED:
// Multiple views can report DRAG_STARTED events, so we only care about one.
if (!mDragInProgress) {
mDragInProgress = true;
backUpOriginalForeground();
}
break;
case ACTION_DRAG_ENDED:
// Multiple views can report DRAG_ENDED events, so we only care about one.
if (mDragInProgress) {
mDragInProgress = false;
restoreOriginalForeground();
mViewsWithDragFocus.clear();
}
break;
case ACTION_DRAG_ENTERED:
mViewsWithDragFocus.add(reportingView);
break;
case ACTION_DRAG_EXITED:
mViewsWithDragFocus.remove(reportingView);
break;
}
if (mDragInProgress) {
// A drag is in progress. We want the darker "selected" highlight as long as the user's
// finger is over one of the relevant views, to indicate that they are over an active
// drop target. Otherwise, we want the lighter highlight. See go/nested-drop for more
// details.
if (!mViewsWithDragFocus.isEmpty()) {
mViewToHighlight.setForeground(mSelectedAffordance);
} else {
mViewToHighlight.setForeground(mHighlightAffordance);
}
}
}
private static GradientDrawable makeDrawable(
Context context,
@ColorInt int highlightColor,
@ColorInt int strokeColor,
int cornerRadiusPx) {
GradientDrawable drawable = new GradientDrawable();
drawable.setShape(GradientDrawable.RECTANGLE);
drawable.setColor(highlightColor);
drawable.setStroke(dpToPx(context, STROKE_WIDTH_DP), strokeColor);
drawable.setCornerRadius(cornerRadiusPx);
return drawable;
}
static int dpToPx(Context context, int valueDp) {
return round(max(0, valueDp) * context.getResources().getDisplayMetrics().density);
}
private void backUpOriginalForeground() {
mOriginalForeground = mViewToHighlight.getForeground();
mOriginalForegroundGravity = mViewToHighlight.getForegroundGravity();
mOriginalForegroundTintList = mViewToHighlight.getForegroundTintList();
mOriginalForegroundTintMode = mViewToHighlight.getForegroundTintMode();
mViewToHighlight.setForegroundGravity(DEFAULT_GRAVITY);
mViewToHighlight.setForegroundTintList(null);
mViewToHighlight.setForegroundTintMode(null);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Api29BackUpImpl.backUp(this);
}
}
private void restoreOriginalForeground() {
mViewToHighlight.setForeground(mOriginalForeground);
mViewToHighlight.setForegroundGravity(mOriginalForegroundGravity);
mViewToHighlight.setForegroundTintList(mOriginalForegroundTintList);
mViewToHighlight.setForegroundTintMode(mOriginalForegroundTintMode);
mOriginalForeground = null;
mOriginalForegroundGravity = DEFAULT_GRAVITY;
mOriginalForegroundTintList = null;
mOriginalForegroundTintMode = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Api29RestoreImpl.restore(this);
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private static class Api29BackUpImpl {
@DoNotInline
static void backUp(DropAffordanceHighlighter highlighter) {
highlighter.mOriginalForegroundTintBlendMode =
highlighter.mViewToHighlight.getForegroundTintBlendMode();
highlighter.mViewToHighlight.setForegroundTintBlendMode(null);
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private static class Api29RestoreImpl {
@DoNotInline
static void restore(DropAffordanceHighlighter highlighter) {
highlighter.mViewToHighlight.setForegroundTintBlendMode(
highlighter.mOriginalForegroundTintBlendMode);
highlighter.mOriginalForegroundTintBlendMode = null;
}
}
/** Builder for {@link DropAffordanceHighlighter}. */
static final class Builder {
private final View mViewToHighlight;
private final Predicate<ClipDescription> mEligibilityPredicate;
private boolean mAcceptDragsWithLocalState;
private int mCornerRadiusPx;
private @ColorInt int mHighlightColor;
private boolean mHighlightColorHasBeenSupplied = false;
Builder(View viewToHighlight, Predicate<ClipDescription> eligibilityPredicate) {
this.mViewToHighlight = viewToHighlight;
this.mEligibilityPredicate = eligibilityPredicate;
mCornerRadiusPx = dpToPx(viewToHighlight.getContext(), DEFAULT_CORNER_RADIUS_DP);
}
/** Sets the color of the affordance highlight. */
@SuppressLint("MissingGetterMatchingBuilder")
@NonNull Builder setHighlightColor(@ColorInt int highlightColor) {
this.mHighlightColor = highlightColor;
this.mHighlightColorHasBeenSupplied = true;
return this;
}
/** Sets the corner radius (px) of the affordance highlight. */
@SuppressLint("MissingGetterMatchingBuilder")
@NonNull Builder setHighlightCornerRadiusPx(int cornerRadiusPx) {
this.mCornerRadiusPx = cornerRadiusPx;
return this;
}
/**
* By default, drag events containing a {@link DragEvent#getLocalState() localState} (and
* therefore coming from this Activity) are ignored. Setting this to true will let them
* be handled.
*/
@SuppressLint("MissingGetterMatchingBuilder")
@NonNull
Builder shouldAcceptDragsWithLocalState(boolean acceptDragsWithLocalState) {
this.mAcceptDragsWithLocalState = acceptDragsWithLocalState;
return this;
}
/** Creates the {@link androidx.draganddrop.DropAffordanceHighlighter}. */
@NonNull DropAffordanceHighlighter build() {
return new DropAffordanceHighlighter(
mViewToHighlight,
mEligibilityPredicate,
mAcceptDragsWithLocalState,
getHighlightColor(),
mCornerRadiusPx);
}
private @ColorInt int getHighlightColor() {
if (mHighlightColorHasBeenSupplied) {
return mHighlightColor;
}
TypedArray values = mViewToHighlight.getContext().obtainStyledAttributes(
new int[]{androidx.appcompat.R.attr.colorAccent});
try {
return values.getColor(0, DEFAULT_COLOR);
} finally {
values.recycle();
}
}
}
}