272 lines
8.2 KiB
Java
272 lines
8.2 KiB
Java
/*
|
|
* Copyright (C) 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 android.view;
|
|
|
|
import static java.util.Objects.requireNonNull;
|
|
|
|
import android.annotation.NonNull;
|
|
import android.annotation.UiThread;
|
|
import android.graphics.Rect;
|
|
import android.os.CancellationSignal;
|
|
import android.util.IndentingPrintWriter;
|
|
|
|
import com.android.internal.annotations.VisibleForTesting;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Comparator;
|
|
import java.util.List;
|
|
import java.util.concurrent.Executor;
|
|
import java.util.function.Consumer;
|
|
|
|
/**
|
|
* Collects nodes in the view hierarchy which have been identified as scrollable content.
|
|
*
|
|
* @hide
|
|
*/
|
|
@UiThread
|
|
public final class ScrollCaptureSearchResults {
|
|
private final Executor mExecutor;
|
|
private final List<ScrollCaptureTarget> mTargets;
|
|
private final CancellationSignal mCancel;
|
|
|
|
private Runnable mOnCompleteListener;
|
|
private int mCompleted;
|
|
private boolean mComplete = true;
|
|
|
|
public ScrollCaptureSearchResults(Executor executor) {
|
|
mExecutor = executor;
|
|
mTargets = new ArrayList<>();
|
|
mCancel = new CancellationSignal();
|
|
}
|
|
|
|
// Public
|
|
|
|
/**
|
|
* Add the given target to the results.
|
|
*
|
|
* @param target the target to consider
|
|
*/
|
|
public void addTarget(@NonNull ScrollCaptureTarget target) {
|
|
requireNonNull(target);
|
|
|
|
mTargets.add(target);
|
|
mComplete = false;
|
|
final ScrollCaptureCallback callback = target.getCallback();
|
|
final Consumer<Rect> consumer = new SearchRequest(target);
|
|
|
|
// Defer so the view hierarchy scan completes first
|
|
mExecutor.execute(
|
|
() -> callback.onScrollCaptureSearch(mCancel, consumer));
|
|
}
|
|
|
|
public boolean isComplete() {
|
|
return mComplete;
|
|
}
|
|
|
|
/**
|
|
* Provides a callback to be invoked as soon as all responses have been received from all
|
|
* targets to this point.
|
|
*
|
|
* @param onComplete listener to add
|
|
*/
|
|
public void setOnCompleteListener(Runnable onComplete) {
|
|
if (mComplete) {
|
|
onComplete.run();
|
|
} else {
|
|
mOnCompleteListener = onComplete;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Indicates whether the search results are empty.
|
|
*
|
|
* @return true if no targets have been added
|
|
*/
|
|
public boolean isEmpty() {
|
|
return mTargets.isEmpty();
|
|
}
|
|
|
|
/**
|
|
* Force the results to complete now, cancelling any pending requests and calling a complete
|
|
* listener if provided.
|
|
*/
|
|
public void finish() {
|
|
if (!mComplete) {
|
|
mCancel.cancel();
|
|
signalComplete();
|
|
}
|
|
}
|
|
|
|
private void signalComplete() {
|
|
mComplete = true;
|
|
mTargets.sort(PRIORITY_ORDER);
|
|
if (mOnCompleteListener != null) {
|
|
mOnCompleteListener.run();
|
|
mOnCompleteListener = null;
|
|
}
|
|
}
|
|
|
|
@VisibleForTesting
|
|
public List<ScrollCaptureTarget> getTargets() {
|
|
return new ArrayList<>(mTargets);
|
|
}
|
|
|
|
/**
|
|
* Get the top ranked result out of all completed requests.
|
|
*
|
|
* @return the top ranked result
|
|
*/
|
|
public ScrollCaptureTarget getTopResult() {
|
|
ScrollCaptureTarget target = mTargets.isEmpty() ? null : mTargets.get(0);
|
|
return target != null && target.getScrollBounds() != null ? target : null;
|
|
}
|
|
|
|
private class SearchRequest implements Consumer<Rect> {
|
|
private ScrollCaptureTarget mTarget;
|
|
|
|
SearchRequest(ScrollCaptureTarget target) {
|
|
mTarget = target;
|
|
}
|
|
|
|
@Override
|
|
public void accept(Rect scrollBounds) {
|
|
if (mTarget == null || mCancel.isCanceled()) {
|
|
return;
|
|
}
|
|
mExecutor.execute(() -> consume(scrollBounds));
|
|
}
|
|
|
|
private void consume(Rect scrollBounds) {
|
|
if (mTarget == null || mCancel.isCanceled()) {
|
|
return;
|
|
}
|
|
if (!nullOrEmpty(scrollBounds)) {
|
|
mTarget.setScrollBounds(scrollBounds);
|
|
mTarget.updatePositionInWindow();
|
|
}
|
|
mCompleted++;
|
|
mTarget = null;
|
|
|
|
// All done?
|
|
if (mCompleted == mTargets.size()) {
|
|
signalComplete();
|
|
}
|
|
}
|
|
}
|
|
|
|
private static final int AFTER = 1;
|
|
private static final int BEFORE = -1;
|
|
private static final int EQUAL = 0;
|
|
|
|
static final Comparator<ScrollCaptureTarget> PRIORITY_ORDER = (a, b) -> {
|
|
if (a == null && b == null) {
|
|
return 0;
|
|
} else if (a == null || b == null) {
|
|
return (a == null) ? 1 : -1;
|
|
}
|
|
|
|
boolean emptyScrollBoundsA = nullOrEmpty(a.getScrollBounds());
|
|
boolean emptyScrollBoundsB = nullOrEmpty(b.getScrollBounds());
|
|
if (emptyScrollBoundsA || emptyScrollBoundsB) {
|
|
if (emptyScrollBoundsA && emptyScrollBoundsB) {
|
|
return EQUAL;
|
|
}
|
|
// Prefer the one with a non-empty scroll bounds
|
|
if (emptyScrollBoundsA) {
|
|
return AFTER;
|
|
}
|
|
return BEFORE;
|
|
}
|
|
|
|
final View viewA = a.getContainingView();
|
|
final View viewB = b.getContainingView();
|
|
|
|
// Prefer any view with scrollCaptureHint="INCLUDE", over one without
|
|
// This is an escape hatch for the next rule (descendants first)
|
|
boolean hintIncludeA = hasIncludeHint(viewA);
|
|
boolean hintIncludeB = hasIncludeHint(viewB);
|
|
if (hintIncludeA != hintIncludeB) {
|
|
return (hintIncludeA) ? BEFORE : AFTER;
|
|
}
|
|
// If the views are relatives, prefer the descendant. This allows implementations to
|
|
// leverage nested scrolling APIs by interacting with the innermost scrollable view (as
|
|
// would happen with touch input).
|
|
if (isDescendant(viewA, viewB)) {
|
|
return BEFORE;
|
|
}
|
|
if (isDescendant(viewB, viewA)) {
|
|
return AFTER;
|
|
}
|
|
|
|
// finally, prefer one with larger scroll bounds
|
|
int scrollAreaA = area(a.getScrollBounds());
|
|
int scrollAreaB = area(b.getScrollBounds());
|
|
return (scrollAreaA >= scrollAreaB) ? BEFORE : AFTER;
|
|
};
|
|
|
|
private static int area(Rect r) {
|
|
return r.width() * r.height();
|
|
}
|
|
|
|
private static boolean nullOrEmpty(Rect r) {
|
|
return r == null || r.isEmpty();
|
|
}
|
|
|
|
private static boolean hasIncludeHint(View view) {
|
|
return (view.getScrollCaptureHint() & View.SCROLL_CAPTURE_HINT_INCLUDE) != 0;
|
|
}
|
|
|
|
/**
|
|
* Determines if {@code otherView} is a descendant of {@code view}.
|
|
*
|
|
* @param view a view
|
|
* @param otherView another view
|
|
* @return true if {@code view} is an ancestor of {@code otherView}
|
|
*/
|
|
private static boolean isDescendant(@NonNull View view, @NonNull View otherView) {
|
|
if (view == otherView) {
|
|
return false;
|
|
}
|
|
ViewParent otherParent = otherView.getParent();
|
|
while (otherParent != view && otherParent != null) {
|
|
otherParent = otherParent.getParent();
|
|
}
|
|
return otherParent == view;
|
|
}
|
|
|
|
void dump(IndentingPrintWriter writer) {
|
|
writer.println("results:");
|
|
writer.increaseIndent();
|
|
writer.println("complete: " + isComplete());
|
|
writer.println("cancelled: " + mCancel.isCanceled());
|
|
writer.println("targets:");
|
|
writer.increaseIndent();
|
|
if (isEmpty()) {
|
|
writer.println("None");
|
|
} else {
|
|
for (int i = 0; i < mTargets.size(); i++) {
|
|
writer.println("[" + i + "]");
|
|
writer.increaseIndent();
|
|
mTargets.get(i).dump(writer);
|
|
writer.decreaseIndent();
|
|
}
|
|
writer.decreaseIndent();
|
|
}
|
|
writer.decreaseIndent();
|
|
}
|
|
}
|