1 /** 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package android.view; 18 19 import static android.view.flags.Flags.dynamicViewRotaryHapticsConfiguration; 20 21 import android.annotation.NonNull; 22 23 import com.android.internal.annotations.VisibleForTesting; 24 25 /** 26 * {@link ScrollFeedbackProvider} that performs haptic feedback when scrolling. 27 * 28 * <p>Each scrolling widget should have its own instance of this class to ensure that scroll state 29 * is isolated. 30 * 31 * <p>Check {@link ScrollFeedbackProvider} for details on the arguments that should be passed to the 32 * methods in this class. To check if your input device ID, source, and motion axis are valid for 33 * haptic feedback, you can use the 34 * {@link ViewConfiguration#isHapticScrollFeedbackEnabled(int, int, int)} API. 35 * 36 * @hide 37 */ 38 public class HapticScrollFeedbackProvider implements ScrollFeedbackProvider { 39 private static final String TAG = "HapticScrollFeedbackProvider"; 40 41 private static final int TICK_INTERVAL_NO_TICK = 0; 42 private static final boolean INITIAL_END_OF_LIST_HAPTICS_ENABLED = false; 43 44 private final View mView; 45 private final ViewConfiguration mViewConfig; 46 /** Whether or not this provider is being used directly by the View class. */ 47 private final boolean mIsFromView; 48 49 50 // Info about the cause of the latest scroll event. 51 /** The ID of the {link @InputDevice} that caused the latest scroll event. */ 52 private int mDeviceId = -1; 53 /** The axis on which the latest scroll event happened. */ 54 private int mAxis = -1; 55 /** The {@link InputDevice} source from which the latest scroll event happened. */ 56 private int mSource = -1; 57 58 /** The tick interval corresponding to the current InputDevice/source/axis. */ 59 private int mTickIntervalPixels = TICK_INTERVAL_NO_TICK; 60 private int mTotalScrollPixels = 0; 61 private boolean mCanPlayLimitFeedback = INITIAL_END_OF_LIST_HAPTICS_ENABLED; 62 private boolean mHapticScrollFeedbackEnabled = false; 63 HapticScrollFeedbackProvider(@onNull View view)64 public HapticScrollFeedbackProvider(@NonNull View view) { 65 this(view, ViewConfiguration.get(view.getContext()), /* isFromView= */ false); 66 } 67 68 /** @hide */ 69 @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) HapticScrollFeedbackProvider( View view, ViewConfiguration viewConfig, boolean isFromView)70 public HapticScrollFeedbackProvider( 71 View view, ViewConfiguration viewConfig, boolean isFromView) { 72 mView = view; 73 mViewConfig = viewConfig; 74 mIsFromView = isFromView; 75 if (dynamicViewRotaryHapticsConfiguration() && !isFromView) { 76 // Disable the View class's rotary scroll feedback logic if this provider is not being 77 // directly used by the View class. This is to avoid double rotary scroll feedback: 78 // one from the View class, and one from this provider instance (i.e. mute the View 79 // class's rotary feedback and enable this provider). 80 view.disableRotaryScrollFeedback(); 81 } 82 } 83 84 @Override onScrollProgress(int inputDeviceId, int source, int axis, int deltaInPixels)85 public void onScrollProgress(int inputDeviceId, int source, int axis, int deltaInPixels) { 86 maybeUpdateCurrentConfig(inputDeviceId, source, axis); 87 if (!mHapticScrollFeedbackEnabled) { 88 return; 89 } 90 91 // Unlock limit feedback regardless of scroll tick being enabled as long as there's a 92 // non-zero scroll progress. 93 if (deltaInPixels != 0) { 94 mCanPlayLimitFeedback = true; 95 } 96 97 if (mTickIntervalPixels == TICK_INTERVAL_NO_TICK) { 98 // There's no valid tick interval. Exit early before doing any further computation. 99 return; 100 } 101 102 mTotalScrollPixels += deltaInPixels; 103 104 if (Math.abs(mTotalScrollPixels) >= mTickIntervalPixels) { 105 mTotalScrollPixels %= mTickIntervalPixels; 106 if (android.os.vibrator.Flags.hapticFeedbackInputSourceCustomizationEnabled()) { 107 mView.performHapticFeedbackForInputDevice( 108 HapticFeedbackConstants.SCROLL_TICK, inputDeviceId, source, /* flags= */ 0); 109 } else { 110 mView.performHapticFeedback(HapticFeedbackConstants.SCROLL_TICK); 111 } 112 } 113 } 114 115 @Override onScrollLimit(int inputDeviceId, int source, int axis, boolean isStart)116 public void onScrollLimit(int inputDeviceId, int source, int axis, boolean isStart) { 117 maybeUpdateCurrentConfig(inputDeviceId, source, axis); 118 if (!mHapticScrollFeedbackEnabled) { 119 return; 120 } 121 122 if (!mCanPlayLimitFeedback) { 123 return; 124 } 125 if (android.os.vibrator.Flags.hapticFeedbackInputSourceCustomizationEnabled()) { 126 mView.performHapticFeedbackForInputDevice( 127 HapticFeedbackConstants.SCROLL_LIMIT, inputDeviceId, source, /* flags= */ 0); 128 } else { 129 mView.performHapticFeedback(HapticFeedbackConstants.SCROLL_LIMIT); 130 } 131 132 mCanPlayLimitFeedback = false; 133 } 134 135 @Override onSnapToItem(int inputDeviceId, int source, int axis)136 public void onSnapToItem(int inputDeviceId, int source, int axis) { 137 maybeUpdateCurrentConfig(inputDeviceId, source, axis); 138 if (!mHapticScrollFeedbackEnabled) { 139 return; 140 } 141 if (android.os.vibrator.Flags.hapticFeedbackInputSourceCustomizationEnabled()) { 142 mView.performHapticFeedbackForInputDevice( 143 HapticFeedbackConstants.SCROLL_ITEM_FOCUS, inputDeviceId, source, 144 /* flags= */ 0); 145 } else { 146 mView.performHapticFeedback(HapticFeedbackConstants.SCROLL_ITEM_FOCUS); 147 } 148 mCanPlayLimitFeedback = true; 149 } 150 maybeUpdateCurrentConfig(int deviceId, int source, int axis)151 private void maybeUpdateCurrentConfig(int deviceId, int source, int axis) { 152 if (mAxis != axis || mSource != source || mDeviceId != deviceId) { 153 mSource = source; 154 mAxis = axis; 155 mDeviceId = deviceId; 156 157 if (!dynamicViewRotaryHapticsConfiguration() 158 && !mIsFromView 159 && (source == InputDevice.SOURCE_ROTARY_ENCODER) 160 && mViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled()) { 161 mHapticScrollFeedbackEnabled = false; 162 return; 163 } 164 165 mHapticScrollFeedbackEnabled = 166 mViewConfig.isHapticScrollFeedbackEnabled(deviceId, axis, source); 167 mCanPlayLimitFeedback = INITIAL_END_OF_LIST_HAPTICS_ENABLED; 168 mTotalScrollPixels = 0; 169 updateTickIntervals(deviceId, source, axis); 170 } 171 } 172 updateTickIntervals(int deviceId, int source, int axis)173 private void updateTickIntervals(int deviceId, int source, int axis) { 174 mTickIntervalPixels = mHapticScrollFeedbackEnabled 175 ? mViewConfig.getHapticScrollFeedbackTickInterval(deviceId, axis, source) 176 : TICK_INTERVAL_NO_TICK; 177 } 178 } 179