TouchDelegateComposite.java

/*
 * Copyright 2023 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.wear.protolayout.renderer.inflater;

import static androidx.core.util.Preconditions.checkNotNull;

import static java.lang.Math.max;

import android.annotation.SuppressLint;
import android.graphics.Rect;
import android.graphics.Region;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.util.ArrayMap;
import android.view.MotionEvent;
import android.view.TouchDelegate;
import android.view.View;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeInfo.TouchDelegateInfo;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import java.util.Map;
import java.util.WeakHashMap;

/**
 * Helper class to handle situations where you want multiple views to have a larger touch area than
 * its actual view bounds. Those views whose touch area is changed is called the delegate view. This
 * class should be used by an ancestor of the delegate. To use a TouchDelegateComposite, first
 * create an instance that specifies the bounds that should be mapped to the delegate and the
 * delegate view itself.
 *
 * <p>The ancestor should then forward all of its touch events received in its {@link
 * android.view.View#onTouchEvent(MotionEvent)} to {@link #onTouchEvent(MotionEvent)}.
 */
class TouchDelegateComposite extends TouchDelegate {

    @NonNull
    private final WeakHashMap<View, DelegateInfo> mDelegates = new WeakHashMap<>();

    /**
     * Constructor
     *
     * @param delegateView   The view that should receive motion events.
     * @param actualBounds   The hit rect of the view.
     * @param extendedBounds The hit rect to be delegated.
     */
    TouchDelegateComposite(
            @NonNull View delegateView, @NonNull Rect actualBounds, @NonNull Rect extendedBounds) {
        super(new Rect(), delegateView);
        mDelegates.put(delegateView, new DelegateInfo(delegateView, actualBounds, extendedBounds));
    }

    @VisibleForTesting
    TouchDelegateComposite(
            @NonNull View delegateView,
            @NonNull Rect actualBounds,
            @NonNull Rect extendedBounds,
            @NonNull TouchDelegate touchDelegate) {
        super(new Rect(), delegateView);
        mDelegates.put(delegateView, new DelegateInfo(actualBounds, extendedBounds, touchDelegate));
    }

    void mergeFrom(@NonNull TouchDelegateComposite touchDelegate) {
        mDelegates.putAll(touchDelegate.mDelegates);
    }

    void removeDelegate(@NonNull View delegateView) {
        mDelegates.remove(delegateView);
    }

    @Override
    public boolean onTouchEvent(@NonNull MotionEvent event) {
        boolean eventForwarded = false;
        float x = event.getX();
        float y = event.getY();
        if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
            // Only forward the ACTION_DOWN touch event to the delegate view whose extended bounds
            // contains the touch point, and with its ACTUAL bound closest to the touch point.
            View view = null;
            int ix = (int) x;
            int iy = (int) y;
            int sqDistance = Integer.MAX_VALUE;
            for (Map.Entry<View, DelegateInfo> entry : mDelegates.entrySet()) {
                if (entry.getValue().mExtendedBounds.contains(ix, iy)) {
                    int sd = squaredDistance(entry.getValue().mActualBounds, ix, iy);
                    if (sd < sqDistance) {
                        sqDistance = sd;
                        view = entry.getKey();
                    }
                }
            }
            if (view == null) {
                return false;
            }
            return checkNotNull(mDelegates.get(view)).mTouchDelegate.onTouchEvent(event);
        } else {
            // For other motion event, forward to ALL the delegate view whose extended bounds
          // with touch
            // slop contains the touch point.
            for (DelegateInfo delegateInfo : mDelegates.values()) {
                // set the event location back to the original coordinates, which might get
              // offset by the
                // previous TouchDelegate#onTouchEvent call
                event.setLocation(x, y);
                eventForwarded |= delegateInfo.mTouchDelegate.onTouchEvent(event);
            }
        }
        return eventForwarded;
    }

    @SuppressLint("ClassVerificationFailure")
    @Override
    @NonNull
    public AccessibilityNodeInfo.TouchDelegateInfo getTouchDelegateInfo() {
        if (VERSION.SDK_INT >= VERSION_CODES.Q) {
            Map<Region, View> targetMap = new ArrayMap<>(mDelegates.size());
            for (Map.Entry<View, DelegateInfo> entry : mDelegates.entrySet()) {
                AccessibilityNodeInfo.TouchDelegateInfo info =
                        entry.getValue().mTouchDelegate.getTouchDelegateInfo();
                if (info.getRegionCount() > 0) {
                    targetMap.put(info.getRegionAt(0), entry.getKey());
                }
            }
            return new TouchDelegateInfo(targetMap);
        } else {
            return super.getTouchDelegateInfo();
        }
    }

    /** Calculate the squared distance from a point to a rectangle. */
    private int squaredDistance(@NonNull Rect rect, int pointX, int pointY) {
        int deltaX = max(max(rect.left - pointX, 0), pointX - rect.right);
        int deltaY = max(max(rect.top - pointY, 0), pointY - rect.bottom);
        return deltaX * deltaX + deltaY * deltaY;
    }

    private static final class DelegateInfo {
        @NonNull
        final Rect mActualBounds;
        @NonNull
        final Rect mExtendedBounds;
        @NonNull
        final TouchDelegate mTouchDelegate;

        DelegateInfo(
                @NonNull View delegateView, @NonNull Rect actualBounds,
                @NonNull Rect extendedBounds) {
            mActualBounds = actualBounds;
            mExtendedBounds = extendedBounds;
            mTouchDelegate = new TouchDelegate(extendedBounds, delegateView);
        }

        private DelegateInfo(
                @NonNull Rect actualBounds,
                @NonNull Rect extendedBounds,
                @NonNull TouchDelegate touchDelegate) {
            mActualBounds = actualBounds;
            mExtendedBounds = extendedBounds;
            mTouchDelegate = touchDelegate;
        }
    }
}