1 /* 2 * Copyright (C) 2020 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 com.android.car.rotaryplayground; 18 19 import android.os.Bundle; 20 import android.view.KeyEvent; 21 import android.view.LayoutInflater; 22 import android.view.MotionEvent; 23 import android.view.View; 24 import android.view.ViewGroup; 25 import android.view.ViewParent; 26 import android.view.accessibility.AccessibilityNodeInfo; 27 import android.widget.NumberPicker; 28 import android.widget.TimePicker; 29 30 import androidx.annotation.Nullable; 31 import androidx.fragment.app.Fragment; 32 33 import java.util.ArrayList; 34 import java.util.Collections; 35 import java.util.HashMap; 36 import java.util.List; 37 import java.util.Map; 38 39 /** 40 * Fragment that demos rotary interactions directly manipulating the state of UI widgets such as a 41 * {@link android.widget.SeekBar}, {@link android.widget.DatePicker}, and 42 * {@link android.widget.RadialTimePickerView}, and {@link DirectManipulationView} in an 43 * application window. 44 */ 45 public class RotaryDirectManipulationWidgets extends Fragment { 46 47 // TODO(agathaman): refactor a common class that takes in a fragment xml id and inflates it, to 48 // share between this and RotaryCards. 49 50 private final DirectManipulationState mDirectManipulationMode = new DirectManipulationState(); 51 52 @Override onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState)53 public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, 54 @Nullable Bundle savedInstanceState) { 55 View view = inflater.inflate(R.layout.rotary_direct_manipulation, container, false); 56 57 DirectManipulationView dmv = view.findViewById(R.id.direct_manipulation_view); 58 registerDirectManipulationHandler(dmv, 59 new DirectManipulationHandler.Builder(mDirectManipulationMode) 60 .setNudgeHandler(new DirectManipulationView.NudgeHandler()) 61 .setRotationHandler(new DirectManipulationView.RotationHandler()) 62 .build()); 63 64 65 TimePicker spinnerTimePicker = view.findViewById(R.id.spinner_time_picker); 66 registerDirectManipulationHandler(spinnerTimePicker, 67 new DirectManipulationHandler.Builder(mDirectManipulationMode) 68 .setNudgeHandler(new TimePickerNudgeHandler()) 69 .build()); 70 71 DirectManipulationHandler numberPickerListener = 72 new DirectManipulationHandler.Builder(mDirectManipulationMode) 73 .setNudgeHandler(new NumberPickerNudgeHandler()) 74 .setRotationHandler((v, motionEvent) -> { 75 float scroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL); 76 View focusedView = v.findFocus(); 77 if (focusedView instanceof NumberPicker) { 78 NumberPicker numberPicker = (NumberPicker) focusedView; 79 numberPicker.setValue(numberPicker.getValue() + Math.round(scroll)); 80 return true; 81 } 82 return false; 83 }) 84 .build(); 85 86 List<NumberPicker> numberPickers = new ArrayList<>(); 87 getNumberPickerDescendants(numberPickers, spinnerTimePicker); 88 for (int i = 0; i < numberPickers.size(); i++) { 89 registerDirectManipulationHandler(numberPickers.get(i), numberPickerListener); 90 } 91 92 registerDirectManipulationHandler(view.findViewById(R.id.clock_time_picker), 93 new DirectManipulationHandler.Builder( 94 mDirectManipulationMode) 95 // TODO(pardis): fix the behavior here. It does not nudge as expected. 96 .setNudgeHandler(new TimePickerNudgeHandler()) 97 .setRotationHandler((v, motionEvent) -> { 98 // TODO(pardis): fix the behavior here. It does not scroll as intended. 99 float scroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL); 100 View focusedView = v.findFocus(); 101 scrollView(focusedView, scroll); 102 return true; 103 }) 104 .build()); 105 106 registerDirectManipulationHandler( 107 view.findViewById(R.id.seek_bar), 108 new DirectManipulationHandler.Builder(mDirectManipulationMode) 109 .setRotationHandler(new DelegateToA11yScrollRotationHandler()) 110 .build()); 111 112 registerDirectManipulationHandler( 113 view.findViewById(R.id.radial_time_picker), 114 new DirectManipulationHandler.Builder(mDirectManipulationMode) 115 .setRotationHandler(new DelegateToA11yScrollRotationHandler()) 116 .build()); 117 118 return view; 119 } 120 121 @Override onPause()122 public void onPause() { 123 if (mDirectManipulationMode.isActive()) { 124 // To ensure that the user doesn't get stuck in direct manipulation mode, disable direct 125 // manipulation mode when the fragment is not interactive (e.g., a dialog shows up). 126 mDirectManipulationMode.disable(); 127 } 128 super.onPause(); 129 } 130 131 /** 132 * Register the given {@link DirectManipulationHandler} as both the 133 * {@link View.OnKeyListener} and {@link View.OnGenericMotionListener} for the given 134 * {@link View}. 135 * <p> 136 * Handles a {@link Nullable} {@link View} so that it can be used directly with the output of 137 * methods such as {@code findViewById}. 138 */ registerDirectManipulationHandler(@ullable View view, DirectManipulationHandler handler)139 private void registerDirectManipulationHandler(@Nullable View view, 140 DirectManipulationHandler handler) { 141 if (view == null) { 142 return; 143 } 144 view.setOnKeyListener(handler); 145 view.setOnGenericMotionListener(handler); 146 } 147 148 /** 149 * A {@link View.OnGenericMotionListener} implementation that delegates handling the 150 * {@link MotionEvent} to the {@link AccessibilityNodeInfo#ACTION_SCROLL_FORWARD} 151 * or {@link AccessibilityNodeInfo#ACTION_SCROLL_BACKWARD} depending on the sign of the 152 * {@link MotionEvent#AXIS_SCROLL} value. 153 */ 154 private static class DelegateToA11yScrollRotationHandler 155 implements View.OnGenericMotionListener { 156 157 @Override onGenericMotion(View v, MotionEvent event)158 public boolean onGenericMotion(View v, MotionEvent event) { 159 scrollView(v, event.getAxisValue(MotionEvent.AXIS_SCROLL)); 160 return true; 161 } 162 } 163 164 /** 165 * A shortcut to "scrolling" a given {@link View} by delegating to A11y actions. Most useful 166 * in scenarios that we do not have API access to the descendants of a {@link ViewGroup} but 167 * also handy for other cases so we don't have to re-implement the behaviors if we already know 168 * that suitable A11y actions exist and are implemented for the relevant views. 169 */ scrollView(View view, float scroll)170 private static void scrollView(View view, float scroll) { 171 for (int i = 0; i < Math.round(Math.abs(scroll)); i++) { 172 view.performAccessibilityAction( 173 scroll > 0 174 ? AccessibilityNodeInfo.ACTION_SCROLL_FORWARD 175 : AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD, 176 /* arguments= */ null); 177 } 178 } 179 180 /** 181 * A {@link View.OnKeyListener} for handling Direct Manipulation rotary nudge behavior 182 * for a {@link NumberPicker}. 183 * 184 * <p> 185 * This handler expects that it is being used in Direct Manipulation mode, i.e. as a directional 186 * delegate through a {@link DirectManipulationHandler} which can invoke it at the 187 * appropriate times. 188 * <p> 189 * Only handles the following {@link KeyEvent}s and in the specified way below: 190 * <ul> 191 * <li>{@link KeyEvent#KEYCODE_DPAD_UP} - explicitly disabled 192 * <li>{@link KeyEvent#KEYCODE_DPAD_DOWN} - explicitly disabled 193 * <li>{@link KeyEvent#KEYCODE_DPAD_LEFT} - nudges left 194 * <li>{@link KeyEvent#KEYCODE_DPAD_RIGHT} - nudges right 195 * </ul> 196 * <p> 197 * This handler only allows nudging left and right to other {@link View} objects within the same 198 * {@link TimePicker}. 199 */ 200 private static class NumberPickerNudgeHandler implements View.OnKeyListener { 201 202 private static final Map<Integer, Integer> KEYCODE_TO_DIRECTION_MAP; 203 204 static { 205 Map<Integer, Integer> map = new HashMap<>(); map.put(KeyEvent.KEYCODE_DPAD_UP, View.FOCUS_UP)206 map.put(KeyEvent.KEYCODE_DPAD_UP, View.FOCUS_UP); map.put(KeyEvent.KEYCODE_DPAD_DOWN, View.FOCUS_DOWN)207 map.put(KeyEvent.KEYCODE_DPAD_DOWN, View.FOCUS_DOWN); map.put(KeyEvent.KEYCODE_DPAD_LEFT, View.FOCUS_LEFT)208 map.put(KeyEvent.KEYCODE_DPAD_LEFT, View.FOCUS_LEFT); map.put(KeyEvent.KEYCODE_DPAD_RIGHT, View.FOCUS_RIGHT)209 map.put(KeyEvent.KEYCODE_DPAD_RIGHT, View.FOCUS_RIGHT); 210 KEYCODE_TO_DIRECTION_MAP = Collections.unmodifiableMap(map); 211 } 212 213 @Override onKey(View v, int keyCode, KeyEvent event)214 public boolean onKey(View v, int keyCode, KeyEvent event) { 215 boolean isActionUp = event.getAction() == KeyEvent.ACTION_UP; 216 switch (keyCode) { 217 case KeyEvent.KEYCODE_DPAD_UP: 218 case KeyEvent.KEYCODE_DPAD_DOWN: 219 // Disable by consuming the event and not doing anything. 220 return true; 221 case KeyEvent.KEYCODE_DPAD_LEFT: 222 case KeyEvent.KEYCODE_DPAD_RIGHT: 223 if (isActionUp) { 224 int direction = KEYCODE_TO_DIRECTION_MAP.get(keyCode); 225 View nextView = v.focusSearch(direction); 226 if (areInTheSameTimePicker(v, nextView)) { 227 nextView.requestFocus(direction); 228 } 229 } 230 return true; 231 default: 232 return false; 233 } 234 } 235 areInTheSameTimePicker(@ullable View view1, @Nullable View view2)236 private static boolean areInTheSameTimePicker(@Nullable View view1, @Nullable View view2) { 237 if (view1 == null || view2 == null) { 238 return false; 239 } 240 TimePicker view1Ancestor = getTimePickerAncestor(view1); 241 TimePicker view2Ancestor = getTimePickerAncestor(view2); 242 return view1Ancestor == view2Ancestor; 243 } 244 245 /* 246 * A generic version of this may come in handy as a library. Any {@link ViewGroup} view that 247 * supports Direct Manipulation mode will need something like this to ensure nudge actions 248 * don't result in navigating outside the parent {link ViewGroup} that is in Direct 249 * Manipulation mode. 250 */ 251 @Nullable getTimePickerAncestor(@ullable View view)252 private static TimePicker getTimePickerAncestor(@Nullable View view) { 253 if (view instanceof TimePicker) { 254 return (TimePicker) view; 255 } 256 ViewParent viewParent = view.getParent(); 257 if (viewParent instanceof View) { 258 return getTimePickerAncestor((View) viewParent); 259 } 260 return null; 261 } 262 } 263 264 /** 265 * A {@link View.OnKeyListener} for handling Direct Manipulation rotary nudge behavior 266 * for a {@link TimePicker}. 267 * <p> 268 * This handler expects that it is being used in Direct Manipulation mode, i.e. as a 269 * directional delegate through a {@link DirectManipulationHandler} which can invoke it at the 270 * appropriate times. 271 * <p> 272 * Only handles the following {@link KeyEvent}s and in the specified way below: 273 * <ul> 274 * <li>{@link KeyEvent#KEYCODE_DPAD_UP} - explicitly disabled 275 * <li>{@link KeyEvent#KEYCODE_DPAD_DOWN} - explicitly disabled 276 * <li>{@link KeyEvent#KEYCODE_DPAD_LEFT} - passes focus to a descendant view 277 * <li>{@link KeyEvent#KEYCODE_DPAD_RIGHT} - passes focus to a descendant view 278 * </ul> 279 * <p> 280 * When passing focus to a descendant, looks for all {@link NumberPicker} views and passes 281 * focus to the first one found. 282 * <p> 283 * This handler expects that any descendant {@link NumberPicker} objects have registered 284 * their own Direct Manipulation handlers via a {@link DirectManipulationHandler}. 285 */ 286 private static class TimePickerNudgeHandler 287 implements View.OnKeyListener { 288 289 @Override onKey(View view, int keyCode, KeyEvent keyEvent)290 public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { 291 if (!(view instanceof TimePicker)) { 292 return false; 293 } 294 boolean isActionUp = keyEvent.getAction() == KeyEvent.ACTION_UP; 295 switch (keyCode) { 296 case KeyEvent.KEYCODE_DPAD_UP: 297 case KeyEvent.KEYCODE_DPAD_DOWN: 298 // TODO(pardis): if intending to reuse this for both time pickers, 299 // then need to make sure it can distinguish between the two. For clock 300 // we may need up and down. 301 // Disable by consuming the event and not doing anything. 302 return true; 303 case KeyEvent.KEYCODE_DPAD_LEFT: 304 case KeyEvent.KEYCODE_DPAD_RIGHT: 305 if (isActionUp) { 306 TimePicker timePicker = (TimePicker) view; 307 List<NumberPicker> numberPickers = new ArrayList<>(); 308 getNumberPickerDescendants(numberPickers, timePicker); 309 if (numberPickers.isEmpty()) { 310 return false; 311 } 312 timePicker.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); 313 numberPickers.get(0).requestFocus(); 314 } 315 return true; 316 default: 317 return false; 318 } 319 } 320 321 } 322 323 /* 324 * We don't have API access to the inner {@link View}s of a {@link TimePicker}. We do know based 325 * on {@code frameworks/base/core/res/res/layout/time_picker_legacy_material.xml} that a 326 * {@link TimePicker} that is in spinner mode will be using {@link NumberPicker}s internally, 327 * and that's what we rely on here. 328 */ getNumberPickerDescendants(List<NumberPicker> numberPickers, ViewGroup v)329 private static void getNumberPickerDescendants(List<NumberPicker> numberPickers, ViewGroup v) { 330 for (int i = 0; i < v.getChildCount(); i++) { 331 View child = v.getChildAt(i); 332 if (child instanceof NumberPicker) { 333 numberPickers.add((NumberPicker) child); 334 } else if (child instanceof ViewGroup) { 335 getNumberPickerDescendants(numberPickers, (ViewGroup) child); 336 } 337 } 338 } 339 } 340