| /** |
| * Copyright (C) 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 android.view; |
| |
| import android.annotation.NonNull; |
| |
| import com.android.internal.annotations.VisibleForTesting; |
| |
| /** |
| * {@link ScrollFeedbackProvider} that performs haptic feedback when scrolling. |
| * |
| * <p>Each scrolling widget should have its own instance of this class to ensure that scroll state |
| * is isolated. |
| * |
| * <p>Check {@link ScrollFeedbackProvider} for details on the arguments that should be passed to the |
| * methods in this class. To check if your input device ID, source, and motion axis are valid for |
| * haptic feedback, you can use the |
| * {@link ViewConfiguration#isHapticScrollFeedbackEnabled(int, int, int)} API. |
| * |
| * @hide |
| */ |
| public class HapticScrollFeedbackProvider implements ScrollFeedbackProvider { |
| private static final String TAG = "HapticScrollFeedbackProvider"; |
| |
| private static final int TICK_INTERVAL_NO_TICK = 0; |
| private static final boolean INITIAL_END_OF_LIST_HAPTICS_ENABLED = false; |
| |
| private final View mView; |
| private final ViewConfiguration mViewConfig; |
| /** |
| * Flag to disable the logic in this class if the View-based scroll haptics implementation is |
| * enabled. If {@code false}, this class will continue to run despite the View's scroll |
| * haptics implementation being enabled. This value should be set to {@code true} when this |
| * class is directly used by the View class. |
| */ |
| private final boolean mDisabledIfViewPlaysScrollHaptics; |
| |
| |
| // Info about the cause of the latest scroll event. |
| /** The ID of the {link @InputDevice} that caused the latest scroll event. */ |
| private int mDeviceId = -1; |
| /** The axis on which the latest scroll event happened. */ |
| private int mAxis = -1; |
| /** The {@link InputDevice} source from which the latest scroll event happened. */ |
| private int mSource = -1; |
| |
| /** The tick interval corresponding to the current InputDevice/source/axis. */ |
| private int mTickIntervalPixels = TICK_INTERVAL_NO_TICK; |
| private int mTotalScrollPixels = 0; |
| private boolean mCanPlayLimitFeedback = INITIAL_END_OF_LIST_HAPTICS_ENABLED; |
| private boolean mHapticScrollFeedbackEnabled = false; |
| |
| public HapticScrollFeedbackProvider(@NonNull View view) { |
| this(view, ViewConfiguration.get(view.getContext()), |
| /* disabledIfViewPlaysScrollHaptics= */ true); |
| } |
| |
| /** @hide */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) |
| public HapticScrollFeedbackProvider( |
| View view, ViewConfiguration viewConfig, boolean disabledIfViewPlaysScrollHaptics) { |
| mView = view; |
| mViewConfig = viewConfig; |
| mDisabledIfViewPlaysScrollHaptics = disabledIfViewPlaysScrollHaptics; |
| } |
| |
| @Override |
| public void onScrollProgress(int inputDeviceId, int source, int axis, int deltaInPixels) { |
| maybeUpdateCurrentConfig(inputDeviceId, source, axis); |
| if (!mHapticScrollFeedbackEnabled) { |
| return; |
| } |
| |
| // Unlock limit feedback regardless of scroll tick being enabled as long as there's a |
| // non-zero scroll progress. |
| if (deltaInPixels != 0) { |
| mCanPlayLimitFeedback = true; |
| } |
| |
| if (mTickIntervalPixels == TICK_INTERVAL_NO_TICK) { |
| // There's no valid tick interval. Exit early before doing any further computation. |
| return; |
| } |
| |
| mTotalScrollPixels += deltaInPixels; |
| |
| if (Math.abs(mTotalScrollPixels) >= mTickIntervalPixels) { |
| mTotalScrollPixels %= mTickIntervalPixels; |
| // TODO(b/239594271): create a new `performHapticFeedbackForDevice` and use that here. |
| mView.performHapticFeedback(HapticFeedbackConstants.SCROLL_TICK); |
| } |
| } |
| |
| @Override |
| public void onScrollLimit(int inputDeviceId, int source, int axis, boolean isStart) { |
| maybeUpdateCurrentConfig(inputDeviceId, source, axis); |
| if (!mHapticScrollFeedbackEnabled) { |
| return; |
| } |
| |
| if (!mCanPlayLimitFeedback) { |
| return; |
| } |
| |
| // TODO(b/239594271): create a new `performHapticFeedbackForDevice` and use that here. |
| mView.performHapticFeedback(HapticFeedbackConstants.SCROLL_LIMIT); |
| |
| mCanPlayLimitFeedback = false; |
| } |
| |
| @Override |
| public void onSnapToItem(int inputDeviceId, int source, int axis) { |
| maybeUpdateCurrentConfig(inputDeviceId, source, axis); |
| if (!mHapticScrollFeedbackEnabled) { |
| return; |
| } |
| // TODO(b/239594271): create a new `performHapticFeedbackForDevice` and use that here. |
| mView.performHapticFeedback(HapticFeedbackConstants.SCROLL_ITEM_FOCUS); |
| mCanPlayLimitFeedback = true; |
| } |
| |
| private void maybeUpdateCurrentConfig(int deviceId, int source, int axis) { |
| if (mAxis != axis || mSource != source || mDeviceId != deviceId) { |
| if (mDisabledIfViewPlaysScrollHaptics |
| && (source == InputDevice.SOURCE_ROTARY_ENCODER) |
| && mViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled()) { |
| mHapticScrollFeedbackEnabled = false; |
| return; |
| } |
| mSource = source; |
| mAxis = axis; |
| mDeviceId = deviceId; |
| |
| mHapticScrollFeedbackEnabled = |
| mViewConfig.isHapticScrollFeedbackEnabled(deviceId, axis, source); |
| mCanPlayLimitFeedback = INITIAL_END_OF_LIST_HAPTICS_ENABLED; |
| mTotalScrollPixels = 0; |
| updateTickIntervals(deviceId, source, axis); |
| } |
| } |
| |
| private void updateTickIntervals(int deviceId, int source, int axis) { |
| mTickIntervalPixels = mHapticScrollFeedbackEnabled |
| ? mViewConfig.getHapticScrollFeedbackTickInterval(deviceId, axis, source) |
| : TICK_INTERVAL_NO_TICK; |
| } |
| } |