blob: 91845975866b571e4e3ac66c69a5d6a88f75ff81 [file] [log] [blame]
/*
* Copyright (C) 2020 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 com.android.car.rotaryplayground;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.NumberPicker;
import android.widget.TimePicker;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fragment that demos rotary interactions directly manipulating the state of UI widgets such as a
* {@link android.widget.SeekBar}, {@link android.widget.DatePicker}, and
* {@link android.widget.RadialTimePickerView}, and {@link DirectManipulationView} in an
* application window.
*/
public class RotaryDirectManipulationWidgets extends Fragment {
// TODO(agathaman): refactor a common class that takes in a fragment xml id and inflates it, to
// share between this and RotaryCards.
private final DirectManipulationState mDirectManipulationMode = new DirectManipulationState();
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.rotary_direct_manipulation, container, false);
DirectManipulationView dmv = view.findViewById(R.id.direct_manipulation_view);
registerDirectManipulationHandler(dmv,
new DirectManipulationHandler.Builder(mDirectManipulationMode)
.setNudgeHandler(new DirectManipulationView.NudgeHandler())
.setRotationHandler(new DirectManipulationView.RotationHandler())
.build());
TimePicker spinnerTimePicker = view.findViewById(R.id.spinner_time_picker);
registerDirectManipulationHandler(spinnerTimePicker,
new DirectManipulationHandler.Builder(mDirectManipulationMode)
.setNudgeHandler(new TimePickerNudgeHandler())
.build());
DirectManipulationHandler numberPickerListener =
new DirectManipulationHandler.Builder(mDirectManipulationMode)
.setNudgeHandler(new NumberPickerNudgeHandler())
.setRotationHandler((v, motionEvent) -> {
float scroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL);
View focusedView = v.findFocus();
if (focusedView instanceof NumberPicker) {
NumberPicker numberPicker = (NumberPicker) focusedView;
numberPicker.setValue(numberPicker.getValue() + Math.round(scroll));
return true;
}
return false;
})
.build();
List<NumberPicker> numberPickers = new ArrayList<>();
getNumberPickerDescendants(numberPickers, spinnerTimePicker);
for (int i = 0; i < numberPickers.size(); i++) {
registerDirectManipulationHandler(numberPickers.get(i), numberPickerListener);
}
registerDirectManipulationHandler(view.findViewById(R.id.clock_time_picker),
new DirectManipulationHandler.Builder(
mDirectManipulationMode)
// TODO(pardis): fix the behavior here. It does not nudge as expected.
.setNudgeHandler(new TimePickerNudgeHandler())
.setRotationHandler((v, motionEvent) -> {
// TODO(pardis): fix the behavior here. It does not scroll as intended.
float scroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL);
View focusedView = v.findFocus();
scrollView(focusedView, scroll);
return true;
})
.build());
registerDirectManipulationHandler(
view.findViewById(R.id.seek_bar),
new DirectManipulationHandler.Builder(mDirectManipulationMode)
.setRotationHandler(new DelegateToA11yScrollRotationHandler())
.build());
registerDirectManipulationHandler(
view.findViewById(R.id.radial_time_picker),
new DirectManipulationHandler.Builder(mDirectManipulationMode)
.setRotationHandler(new DelegateToA11yScrollRotationHandler())
.build());
return view;
}
@Override
public void onPause() {
if (mDirectManipulationMode.isActive()) {
// To ensure that the user doesn't get stuck in direct manipulation mode, disable direct
// manipulation mode when the fragment is not interactive (e.g., a dialog shows up).
mDirectManipulationMode.disable();
}
super.onPause();
}
/**
* Register the given {@link DirectManipulationHandler} as both the
* {@link View.OnKeyListener} and {@link View.OnGenericMotionListener} for the given
* {@link View}.
* <p>
* Handles a {@link Nullable} {@link View} so that it can be used directly with the output of
* methods such as {@code findViewById}.
*/
private void registerDirectManipulationHandler(@Nullable View view,
DirectManipulationHandler handler) {
if (view == null) {
return;
}
view.setOnKeyListener(handler);
view.setOnGenericMotionListener(handler);
}
/**
* A {@link View.OnGenericMotionListener} implementation that delegates handling the
* {@link MotionEvent} to the {@link AccessibilityNodeInfo#ACTION_SCROLL_FORWARD}
* or {@link AccessibilityNodeInfo#ACTION_SCROLL_BACKWARD} depending on the sign of the
* {@link MotionEvent#AXIS_SCROLL} value.
*/
private static class DelegateToA11yScrollRotationHandler
implements View.OnGenericMotionListener {
@Override
public boolean onGenericMotion(View v, MotionEvent event) {
scrollView(v, event.getAxisValue(MotionEvent.AXIS_SCROLL));
return true;
}
}
/**
* A shortcut to "scrolling" a given {@link View} by delegating to A11y actions. Most useful
* in scenarios that we do not have API access to the descendants of a {@link ViewGroup} but
* also handy for other cases so we don't have to re-implement the behaviors if we already know
* that suitable A11y actions exist and are implemented for the relevant views.
*/
private static void scrollView(View view, float scroll) {
for (int i = 0; i < Math.round(Math.abs(scroll)); i++) {
view.performAccessibilityAction(
scroll > 0
? AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
: AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD,
/* arguments= */ null);
}
}
/**
* A {@link View.OnKeyListener} for handling Direct Manipulation rotary nudge behavior
* for a {@link NumberPicker}.
*
* <p>
* This handler expects that it is being used in Direct Manipulation mode, i.e. as a directional
* delegate through a {@link DirectManipulationHandler} which can invoke it at the
* appropriate times.
* <p>
* Only handles the following {@link KeyEvent}s and in the specified way below:
* <ul>
* <li>{@link KeyEvent#KEYCODE_DPAD_UP} - explicitly disabled
* <li>{@link KeyEvent#KEYCODE_DPAD_DOWN} - explicitly disabled
* <li>{@link KeyEvent#KEYCODE_DPAD_LEFT} - nudges left
* <li>{@link KeyEvent#KEYCODE_DPAD_RIGHT} - nudges right
* </ul>
* <p>
* This handler only allows nudging left and right to other {@link View} objects within the same
* {@link TimePicker}.
*/
private static class NumberPickerNudgeHandler implements View.OnKeyListener {
private static final Map<Integer, Integer> KEYCODE_TO_DIRECTION_MAP;
static {
Map<Integer, Integer> map = new HashMap<>();
map.put(KeyEvent.KEYCODE_DPAD_UP, View.FOCUS_UP);
map.put(KeyEvent.KEYCODE_DPAD_DOWN, View.FOCUS_DOWN);
map.put(KeyEvent.KEYCODE_DPAD_LEFT, View.FOCUS_LEFT);
map.put(KeyEvent.KEYCODE_DPAD_RIGHT, View.FOCUS_RIGHT);
KEYCODE_TO_DIRECTION_MAP = Collections.unmodifiableMap(map);
}
@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {
boolean isActionUp = event.getAction() == KeyEvent.ACTION_UP;
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_DOWN:
// Disable by consuming the event and not doing anything.
return true;
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_DPAD_RIGHT:
if (isActionUp) {
int direction = KEYCODE_TO_DIRECTION_MAP.get(keyCode);
View nextView = v.focusSearch(direction);
if (areInTheSameTimePicker(v, nextView)) {
nextView.requestFocus(direction);
}
}
return true;
default:
return false;
}
}
private static boolean areInTheSameTimePicker(@Nullable View view1, @Nullable View view2) {
if (view1 == null || view2 == null) {
return false;
}
TimePicker view1Ancestor = getTimePickerAncestor(view1);
TimePicker view2Ancestor = getTimePickerAncestor(view2);
return view1Ancestor == view2Ancestor;
}
/*
* A generic version of this may come in handy as a library. Any {@link ViewGroup} view that
* supports Direct Manipulation mode will need something like this to ensure nudge actions
* don't result in navigating outside the parent {link ViewGroup} that is in Direct
* Manipulation mode.
*/
@Nullable
private static TimePicker getTimePickerAncestor(@Nullable View view) {
if (view instanceof TimePicker) {
return (TimePicker) view;
}
ViewParent viewParent = view.getParent();
if (viewParent instanceof View) {
return getTimePickerAncestor((View) viewParent);
}
return null;
}
}
/**
* A {@link View.OnKeyListener} for handling Direct Manipulation rotary nudge behavior
* for a {@link TimePicker}.
* <p>
* This handler expects that it is being used in Direct Manipulation mode, i.e. as a
* directional delegate through a {@link DirectManipulationHandler} which can invoke it at the
* appropriate times.
* <p>
* Only handles the following {@link KeyEvent}s and in the specified way below:
* <ul>
* <li>{@link KeyEvent#KEYCODE_DPAD_UP} - explicitly disabled
* <li>{@link KeyEvent#KEYCODE_DPAD_DOWN} - explicitly disabled
* <li>{@link KeyEvent#KEYCODE_DPAD_LEFT} - passes focus to a descendant view
* <li>{@link KeyEvent#KEYCODE_DPAD_RIGHT} - passes focus to a descendant view
* </ul>
* <p>
* When passing focus to a descendant, looks for all {@link NumberPicker} views and passes
* focus to the first one found.
* <p>
* This handler expects that any descendant {@link NumberPicker} objects have registered
* their own Direct Manipulation handlers via a {@link DirectManipulationHandler}.
*/
private static class TimePickerNudgeHandler
implements View.OnKeyListener {
@Override
public boolean onKey(View view, int keyCode, KeyEvent keyEvent) {
if (!(view instanceof TimePicker)) {
return false;
}
boolean isActionUp = keyEvent.getAction() == KeyEvent.ACTION_UP;
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_DOWN:
// TODO(pardis): if intending to reuse this for both time pickers,
// then need to make sure it can distinguish between the two. For clock
// we may need up and down.
// Disable by consuming the event and not doing anything.
return true;
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_DPAD_RIGHT:
if (isActionUp) {
TimePicker timePicker = (TimePicker) view;
List<NumberPicker> numberPickers = new ArrayList<>();
getNumberPickerDescendants(numberPickers, timePicker);
if (numberPickers.isEmpty()) {
return false;
}
timePicker.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
numberPickers.get(0).requestFocus();
}
return true;
default:
return false;
}
}
}
/*
* We don't have API access to the inner {@link View}s of a {@link TimePicker}. We do know based
* on {@code frameworks/base/core/res/res/layout/time_picker_legacy_material.xml} that a
* {@link TimePicker} that is in spinner mode will be using {@link NumberPicker}s internally,
* and that's what we rely on here.
*/
private static void getNumberPickerDescendants(List<NumberPicker> numberPickers, ViewGroup v) {
for (int i = 0; i < v.getChildCount(); i++) {
View child = v.getChildAt(i);
if (child instanceof NumberPicker) {
numberPickers.add((NumberPicker) child);
} else if (child instanceof ViewGroup) {
getNumberPickerDescendants(numberPickers, (ViewGroup) child);
}
}
}
}