ViewConfigurationCompat.java

/*
 * Copyright 2018 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.core.view;

import android.content.Context;
import android.content.res.Resources;
import android.hardware.input.InputManager;
import android.os.Build;
import android.util.Log;
import android.util.TypedValue;
import android.view.InputDevice;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.ViewConfiguration;

import androidx.annotation.DoNotInline;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;

import java.lang.reflect.Method;

/**
 * Helper for accessing features in {@link ViewConfiguration}.
 */
@SuppressWarnings("JavaReflectionMemberAccess")
public final class ViewConfigurationCompat {
    private static final String TAG = "ViewConfigCompat";

    private static Method sGetScaledScrollFactorMethod;

    static {
        if (Build.VERSION.SDK_INT == 25) {
            try {
                sGetScaledScrollFactorMethod =
                        ViewConfiguration.class.getDeclaredMethod("getScaledScrollFactor");
            } catch (Exception e) {
                Log.i(TAG, "Could not find method getScaledScrollFactor() on ViewConfiguration");
            }
        }
    }

    /**
     * Call {@link ViewConfiguration#getScaledPagingTouchSlop()}.
     *
     * @deprecated Call {@link ViewConfiguration#getScaledPagingTouchSlop()} directly.
     * This method will be removed in a future release.
     */
    @Deprecated
    public static int getScaledPagingTouchSlop(ViewConfiguration config) {
        return config.getScaledPagingTouchSlop();
    }

    /**
     * Report if the device has a permanent menu key available to the user, in a backwards
     * compatible way.
     *
     * @deprecated Use {@link ViewConfiguration#hasPermanentMenuKey()} directly.
     */
    @Deprecated
    public static boolean hasPermanentMenuKey(ViewConfiguration config) {
        return config.hasPermanentMenuKey();
    }

    /**
     * @param config Used to get the scaling factor directly from the {@link ViewConfiguration}.
     * @param context Used to locate a resource value.
     *
     * @return Amount to scroll in response to a horizontal {@link MotionEventCompat#ACTION_SCROLL}
     *         event. Multiply this by the event's axis value to obtain the number of pixels to be
     *         scrolled.
     */
    public static float getScaledHorizontalScrollFactor(@NonNull ViewConfiguration config,
            @NonNull Context context) {
        if (Build.VERSION.SDK_INT >= 26) {
            return Api26Impl.getScaledHorizontalScrollFactor(config);
        } else {
            return getLegacyScrollFactor(config, context);
        }
    }

    /**
     * @param config Used to get the scaling factor directly from the {@link ViewConfiguration}.
     * @param context Used to locate a resource value.
     *
     * @return Amount to scroll in response to a vertical {@link MotionEventCompat#ACTION_SCROLL}
     *         event. Multiply this by the event's axis value to obtain the number of pixels to be
     *         scrolled.
     */
    public static float getScaledVerticalScrollFactor(@NonNull ViewConfiguration config,
            @NonNull Context context) {
        if (Build.VERSION.SDK_INT >= 26) {
            return Api26Impl.getScaledVerticalScrollFactor(config);
        } else {
            return getLegacyScrollFactor(config, context);
        }
    }

    @SuppressWarnings("ConstantConditions")
    private static float getLegacyScrollFactor(ViewConfiguration config, Context context) {
        if (Build.VERSION.SDK_INT >= 25 && sGetScaledScrollFactorMethod != null) {
            try {
                return (int) sGetScaledScrollFactorMethod.invoke(config);
            } catch (Exception e) {
                Log.i(TAG, "Could not find method getScaledScrollFactor() on ViewConfiguration");
            }
        }
        // Fall back to pre-API-25 behavior.
        TypedValue outValue = new TypedValue();
        if (context.getTheme().resolveAttribute(
                android.R.attr.listPreferredItemHeight, outValue, true)) {
            return outValue.getDimension(context.getResources().getDisplayMetrics());
        }
        return 0;
    }

    /**
     * @param config Used to get the hover slop directly from the {@link ViewConfiguration}.
     *
     * @return The hover slop value.
     */
    public static int getScaledHoverSlop(@NonNull ViewConfiguration config) {
        if (Build.VERSION.SDK_INT >= 28) {
            return Api28Impl.getScaledHoverSlop(config);
        }
        return config.getScaledTouchSlop() / 2;
    }

    /**
     * Check if shortcuts should be displayed in menus.
     *
     * @return {@code True} if shortcuts should be displayed in menus.
     */
    public static boolean shouldShowMenuShortcutsWhenKeyboardPresent(
            @NonNull ViewConfiguration config,
            @NonNull Context context) {
        if (Build.VERSION.SDK_INT >= 28) {
            return Api28Impl.shouldShowMenuShortcutsWhenKeyboardPresent(config);
        }
        final Resources res = context.getResources();
        final int platformResId =
                getPlatformResId(res, "config_showMenuShortcutsWhenKeyboardPresent", "bool");
        return platformResId != 0 && res.getBoolean(platformResId);
    }

    /**
     * Minimum absolute value of velocity to initiate a fling for a motion generated by an
     * {@link InputDevice} with an id of {@code inputDeviceId}, from an input {@code source} and on
     * a given motion event {@code axis}.
     *
     * <p>Before utilizing this method to get a minimum fling velocity for a motion generated by the
     * input device, scale the velocity of the motion events generated by the input device to pixels
     * per second.
     *
     * <p>For instance, if you tracked {@link MotionEvent#AXIS_SCROLL} vertical velocities generated
     * from a {@link InputDevice#SOURCE_ROTARY_ENCODER}, the velocity returned from
     * {@link VelocityTracker} will be in the units with which the axis values were reported in the
     * motion event. Before comparing that velocity against the minimum fling velocity specified
     * here, make sure that the {@link MotionEvent#AXIS_SCROLL} velocity from the tracker is
     * calculated in "units per second" (see {@link VelocityTracker#computeCurrentVelocity(int)},
     * {@link VelocityTracker#computeCurrentVelocity(int, float)} to adjust your velocity
     * computations to "per second"), and use {@link #getScaledVerticalScrollFactor} to change this
     * velocity value to "pixels/second".
     *
     * <p>If the provided {@code inputDeviceId} is not valid, or if the input device whose ID is
     * provided does not support the given motion event source and/or axis, this method will return
     * {@code Integer.MAX_VALUE}.
     *
     * <h3>Obtaining the correct arguments for this method call</h3>
     * <p><b>inputDeviceId</b>: if calling this method in response to a {@link MotionEvent}, use
     * the device ID that is reported by the event, which can be obtained using
     * {@link MotionEvent#getDeviceId()}. Otherwise, use a valid ID that is obtained from
     * {@link InputDevice#getId()}, or from an {@link InputManager} instance
     * ({@link InputManager#getInputDeviceIds()} gives all the valid input device IDs).
     *
     * <p><b>axis</b>: a {@link MotionEvent} may report data for multiple axes, and each axis may
     * have multiple data points for different pointers. Use the axis for which you obtained the
     * velocity for ({@link VelocityTracker} lets you calculate velocities for a specific axis. Use
     * the axis for which you calculated velocity). You can use
     * {@link InputDevice#getMotionRanges()} to get all the {@link InputDevice.MotionRange}s for the
     * {@link InputDevice}, from which you can derive all the valid axes for the device.
     *
     * <p><b>source</b>: use {@link MotionEvent#getSource()} if calling this method in response to a
     * {@link MotionEvent}. Otherwise, use a valid source for the {@link InputDevice}. You can use
     * {@link InputDevice#getMotionRanges()} to get all the {@link InputDevice.MotionRange}s for the
     * {@link InputDevice}, from which you can derive all the valid sources for the device.
     *
     *
     * <p>This method optimizes calls over multiple input device IDs, so caching the return value of
     * the method is not necessary if you are handling multiple input devices.
     *
     * @param context the {@link Context} associated with the view.
     * @param config the {@link ViewConfiguration} to derive the minimum fling velocity from.
     * @param inputDeviceId the ID of the {@link InputDevice} that generated the motion triggering
     *          fling.
     * @param axis the axis on which the motion triggering the fling happened. This axis should be
     *          a valid axis that can be reported by the provided input device from the provided
     *          input device source.
     * @param source the input source of the motion causing fling. This source should be a valid
     *          source for the {@link InputDevice} whose ID is {@code inputDeviceId}.
     *
     * @return the minimum velocity, in pixels/second, to trigger fling.
     *
     * @see InputDevice#getMotionRange(int, int)
     * @see InputDevice#getMotionRanges()
     * @see VelocityTracker#getAxisVelocity(int, int)
     * @see VelocityTracker#getAxisVelocity(int)
     */
    public static int getScaledMinimumFlingVelocity(
            @NonNull Context context,
            @NonNull ViewConfiguration config,
            int inputDeviceId,
            int axis,
            int source) {
        if (Build.VERSION.SDK_INT >= 34) {
            return Api34Impl.getScaledMinimumFlingVelocity(config, inputDeviceId, axis, source);
        }

        if (!isInputDeviceInfoValid(inputDeviceId, axis, source)) {
            return Integer.MAX_VALUE;
        }

        Resources res = context.getResources();
        int platformResId = getPreApi34MinimumFlingVelocityResId(res, source, axis);
        if (platformResId != 0) {
            int minFlingVelocity = res.getDimensionPixelSize(platformResId);
            return minFlingVelocity < 0 ? Integer.MAX_VALUE : minFlingVelocity;
        }

        return config.getScaledMinimumFlingVelocity();
    }

    /**
     * Maximum absolute value of velocity to initiate a fling for a motion generated by an
     * {@link InputDevice} with an id of {@code inputDeviceId}, from an input {@code source} and on
     * a given motion event {@code axis}.
     *
     * <p>Similar to
     * {@link #getScaledMinimumFlingVelocity(Context, ViewConfiguration, int, int, int)}, but for
     * maximum fling velocity, instead of minimum. Also, unlike that method which returns
     * {@code Integer.MAX_VALUE} for bad input device ID, source and/or motion event axis inputs,
     * this method returns {@code Integer.MIN_VALUE} for such bad inputs.
     */
    public static int getScaledMaximumFlingVelocity(
            @NonNull Context context,
            @NonNull ViewConfiguration config,
            int inputDeviceId,
            int axis,
            int source) {
        if (Build.VERSION.SDK_INT >= 34) {
            return Api34Impl.getScaledMaximumFlingVelocity(config, inputDeviceId, axis, source);
        }

        if (!isInputDeviceInfoValid(inputDeviceId, axis, source)) {
            return Integer.MIN_VALUE;
        }

        Resources res = context.getResources();
        int platformResId = getPreApi34MaximumFlingVelocityResId(res, source, axis);
        if (platformResId != 0) {
            int maxFlingVelocity = res.getDimensionPixelSize(platformResId);
            return maxFlingVelocity < 0 ? Integer.MIN_VALUE : maxFlingVelocity;
        }

        return config.getScaledMaximumFlingVelocity();
    }

    private ViewConfigurationCompat() {
    }

    @RequiresApi(26)
    static class Api26Impl {
        private Api26Impl() {
            // This class is not instantiable.
        }

        @DoNotInline
        static float getScaledHorizontalScrollFactor(ViewConfiguration viewConfiguration) {
            return viewConfiguration.getScaledHorizontalScrollFactor();
        }

        @DoNotInline
        static float getScaledVerticalScrollFactor(ViewConfiguration viewConfiguration) {
            return viewConfiguration.getScaledVerticalScrollFactor();
        }
    }

    @RequiresApi(28)
    static class Api28Impl {
        private Api28Impl() {
            // This class is not instantiable.
        }

        @DoNotInline
        static int getScaledHoverSlop(ViewConfiguration viewConfiguration) {
            return viewConfiguration.getScaledHoverSlop();
        }

        @DoNotInline
        static boolean shouldShowMenuShortcutsWhenKeyboardPresent(
                ViewConfiguration viewConfiguration) {
            return viewConfiguration.shouldShowMenuShortcutsWhenKeyboardPresent();
        }
    }

    @RequiresApi(34)
    static class Api34Impl {
        private Api34Impl() {
            // This class is not instantiable.
        }

        @DoNotInline
        static int getScaledMaximumFlingVelocity(
                @NonNull ViewConfiguration viewConfiguration,
                int inputDeviceId,
                int axis,
                int source) {
            return viewConfiguration.getScaledMaximumFlingVelocity(inputDeviceId, axis, source);
        }

        @DoNotInline
        static int getScaledMinimumFlingVelocity(
                @NonNull ViewConfiguration viewConfiguration,
                int inputDeviceId,
                int axis,
                int source) {
            return viewConfiguration.getScaledMinimumFlingVelocity(inputDeviceId, axis, source);
        }
    }

    private static int getPreApi34MaximumFlingVelocityResId(Resources res, int source, int axis) {
        if (source == InputDeviceCompat.SOURCE_ROTARY_ENCODER && axis == MotionEvent.AXIS_SCROLL) {
            return getPlatformResId(res, "config_viewMaxRotaryEncoderFlingVelocity", "dimen");
        }
        return 0;
    }

    private static int getPreApi34MinimumFlingVelocityResId(Resources res, int source, int axis) {
        if (source == InputDeviceCompat.SOURCE_ROTARY_ENCODER && axis == MotionEvent.AXIS_SCROLL) {
            return getPlatformResId(res, "config_viewMinRotaryEncoderFlingVelocity", "dimen");
        }
        return 0;
    }

    private static int getPlatformResId(Resources res, String name, String defType) {
        return res.getIdentifier(name, defType, /* defPackage= */ "android");
    }

    private static boolean isInputDeviceInfoValid(int id, int axis, int source) {
        InputDevice device = InputDevice.getDevice(id);
        return device != null && device.getMotionRange(axis, source) != null;
    }
}