aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTreeHugger Robot <treehugger-gerrit@google.com>2020-05-28 21:52:00 +0000
committerAutomerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>2020-05-28 21:52:00 +0000
commit862c0788aabf3c43d48bd765a7949db61e9a52c3 (patch)
treec282fc6a863a81f02a2e5aa27fb39cbaaddcb18a
parent01b170af744de634ee81df1ba1634eda92290064 (diff)
parent6bf19c93bbe859b3af4fd0930c55d89337923596 (diff)
downloadtests-862c0788aabf3c43d48bd765a7949db61e9a52c3.tar.gz
Merge "Add listeners for the direct manipulation example widgets." into rvc-dev am: 6bf19c93bb
Change-Id: I1fb3286f382f2c4db442474b2333ba9e015de3ac
-rw-r--r--RotaryPlayground/res/layout/rotary_direct_manipulation.xml4
-rw-r--r--RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationHandler.java189
-rw-r--r--RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationState.java53
-rw-r--r--RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java310
4 files changed, 540 insertions, 16 deletions
diff --git a/RotaryPlayground/res/layout/rotary_direct_manipulation.xml b/RotaryPlayground/res/layout/rotary_direct_manipulation.xml
index 5b385b3..6f6990e 100644
--- a/RotaryPlayground/res/layout/rotary_direct_manipulation.xml
+++ b/RotaryPlayground/res/layout/rotary_direct_manipulation.xml
@@ -33,6 +33,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<TimePicker
+ android:id="@+id/spinner_time_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:focusable="true"
@@ -44,6 +45,7 @@
android:layout_height="0dp"
android:layout_weight="1">
<TimePicker
+ android:id="@+id/clock_time_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:focusable="true"
@@ -59,11 +61,13 @@
android:orientation="vertical"
android:layout_weight="1">
<SeekBar
+ android:id="@+id/seek_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/seek_bar_background">
</SeekBar>
<RadialTimePickerView
+ android:id="@+id/radial_time_picker"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationHandler.java b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationHandler.java
new file mode 100644
index 0000000..5773b04
--- /dev/null
+++ b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationHandler.java
@@ -0,0 +1,189 @@
+/*
+ * 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.graphics.Color;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+import androidx.core.util.Preconditions;
+
+import com.android.car.ui.utils.DirectManipulationHelper;
+
+/**
+ * A {@link View.OnKeyListener} and {@link View.OnGenericMotionListener} that adds a
+ * "Direct Manipulation" mode to any {@link View} that uses it.
+ * <p>
+ * Direct Manipulation mode in the Rotary context is a mode in which the user can use the
+ * Rotary controls to manipulate and change the UI elements they are interacting with rather
+ * than navigate through the entire UI.
+ * <p>
+ * Treats {@link KeyEvent#KEYCODE_DPAD_CENTER} as the signal to enter Direct Manipulation
+ * mode, and {@link KeyEvent#KEYCODE_BACK} as the signal to exit, and keeps track of which
+ * mode the {@link View} using it is currently in.
+ * <p>
+ * When in Direct Manipulation mode, it delegates to {@code mDirectionalDelegate}
+ * for handling nudge behavior and {@code mMotionDelegate} for rotation behavior. Generally
+ * it is expected that in Direct Manipulation mode, nudges are used for navigation and
+ * rotation is used for "manipulating" the value of the selected {@link View}.
+ * <p>
+ * To reduce boilerplate, this class provides "no op" nudge and rotation behavior if
+ * no {@link View.OnKeyListener} or {@link View.OnGenericMotionListener} are provided as
+ * delegates for tackling the relevant events.
+ * <p>
+ * Allows {@link View}s that are within a {@link ViewGroup} to provide a link to their
+ * ancestor {@link ViewGroup} from which Direct Manipulation mode was first enabled. That way
+ * when the user finally exits Direct Manipulation mode, both objects are restored to their
+ * original state.
+ */
+public class DirectManipulationHandler implements View.OnKeyListener,
+ View.OnGenericMotionListener {
+
+ /** Background color of a view when it's in direct manipulation mode. */
+ private static final int BACKGROUND_COLOR_IN_DIRECT_MANIPULATION_MODE = Color.BLUE;
+
+ /** Background color of a view when it's not in direct manipulation mode. */
+ private static final int BACKGROUND_COLOR_NOT_IN_DIRECT_MANIPULATION_MODE = Color.TRANSPARENT;
+
+ private final DirectManipulationState mDirectManipulationMode;
+ private final View.OnKeyListener mNudgeDelegate;
+ private final View.OnGenericMotionListener mRotationDelegate;
+
+ /**
+ * A builder for {@link DirectManipulationHandler}.
+ */
+ public static class Builder {
+ private final DirectManipulationState mDmState;
+ private View.OnKeyListener mNudgeDelegate;
+ private View.OnGenericMotionListener mRotationDelegate;
+
+ public Builder(DirectManipulationState dmState) {
+ Preconditions.checkNotNull(dmState);
+ this.mDmState = dmState;
+ }
+
+ public Builder setNudgeHandler(View.OnKeyListener directionalDelegate) {
+ Preconditions.checkNotNull(directionalDelegate);
+ this.mNudgeDelegate = directionalDelegate;
+ return this;
+ }
+
+ public Builder setRotationHandler(View.OnGenericMotionListener motionDelegate) {
+ Preconditions.checkNotNull(motionDelegate);
+ this.mRotationDelegate = motionDelegate;
+ return this;
+ }
+
+ public DirectManipulationHandler build() {
+ if (mNudgeDelegate == null && mRotationDelegate == null) {
+ throw new IllegalStateException("At least one delegate must be provided.");
+ }
+ return new DirectManipulationHandler(mDmState, mNudgeDelegate, mRotationDelegate);
+ }
+ }
+
+ private DirectManipulationHandler(DirectManipulationState dmState,
+ @Nullable View.OnKeyListener nudgeDelegate,
+ @Nullable View.OnGenericMotionListener rotationDelegate) {
+ Preconditions.checkNotNull(dmState);
+ mDirectManipulationMode = dmState;
+ mNudgeDelegate = nudgeDelegate;
+ mRotationDelegate = rotationDelegate;
+ }
+
+ @Override
+ public boolean onKey(View view, int keyCode, KeyEvent keyEvent) {
+ boolean isActionUp = keyEvent.getAction() == KeyEvent.ACTION_UP;
+ Log.d("RotaryPlayGround", "View: " + view + "\n is handling " + keyCode
+ + " and action " + keyEvent.getAction()
+ + " having entered direct manipulation mode from "
+ + mDirectManipulationMode.getStartingView());
+
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ // If not yet in Direct Manipulation mode, switch to that mode.
+ // We generally want to give some kind of visual indication that this change
+ // has happened. In this example we change the background color.
+ if (!mDirectManipulationMode.isActive() && isActionUp) {
+ mDirectManipulationMode.setStartingView(view);
+ /*
+ * A more robust approach would be to fetch the current background color from
+ * the view object and store it back onto the View itself using the {@link
+ * View#setTag(int, java.lang.Object)} API. This could then be fetched back
+ * and used to restore the background color without needing to keep a constant
+ * reference to the color here which could fall out of sync with the xml files.
+ */
+ view.setBackgroundColor(BACKGROUND_COLOR_IN_DIRECT_MANIPULATION_MODE);
+ DirectManipulationHelper.enableDirectManipulationMode(view, true);
+ }
+ return true;
+ case KeyEvent.KEYCODE_BACK:
+ // If in Direct Manipulation mode, exit, and clean up state.
+ if (mDirectManipulationMode.isActive() && isActionUp) {
+ // This may or may not be the same as argument v. It is possible
+ // for us to enter Direct Manipulation mode from view A and exit from
+ // view B. dmStartingView represents A and v represents B.
+ View dmStartingView = mDirectManipulationMode.getStartingView();
+ // For ViewGroup objects, restore descendant focusability to
+ // FOCUS_BLOCK_DESCENDANTS so during non-Direct Manipulation mode, aka,
+ // general rotary navigation, we don't go through the individual inner UI
+ // elements.
+ if (dmStartingView instanceof ViewGroup) {
+ ViewGroup viewGroup = (ViewGroup) dmStartingView;
+ viewGroup.setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS);
+ }
+ // Restore any visual indicators that the view was in Direct Manipulation.
+ dmStartingView.setBackgroundColor(
+ BACKGROUND_COLOR_NOT_IN_DIRECT_MANIPULATION_MODE);
+ // Actually go ahead and disable Direct Manipulation mode for the view.
+ DirectManipulationHelper.enableDirectManipulationMode(dmStartingView, false);
+ // Update mode.
+ mDirectManipulationMode.setStartingView(null);
+ }
+ return true;
+ default:
+ // This handler is only responsible for behavior during Direct Manipulation
+ // mode. When the mode is disabled, ignore events.
+ if (!mDirectManipulationMode.isActive()) {
+ return false;
+ }
+ // If no delegate present, ignore events.
+ if (mNudgeDelegate == null) {
+ return false;
+ }
+ return mNudgeDelegate.onKey(view, keyCode, keyEvent);
+ }
+ }
+
+ @Override
+ public boolean onGenericMotion(View v, MotionEvent event) {
+ // This handler is only responsible for behavior during Direct Manipulation
+ // mode. When the mode is disabled, ignore events.
+ if (!mDirectManipulationMode.isActive()) {
+ return false;
+ }
+ // If no delegate present, ignore events.
+ if (mRotationDelegate == null) {
+ return false;
+ }
+ return mRotationDelegate.onGenericMotion(v, event);
+ }
+}
diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationState.java b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationState.java
new file mode 100644
index 0000000..0227643
--- /dev/null
+++ b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationState.java
@@ -0,0 +1,53 @@
+/*
+ * 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.view.View;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Keeps track of the state of "direct manipulation" Rotary mode for this application window by
+ * tracking a reference to the {@link View} from which the user first enters into "direct
+ * manipulation" mode.
+ *
+ * <p>See {@link DirectManipulationHandler} for a definition of "direct manipulation".
+ */
+public class DirectManipulationState {
+
+ /** The view that is in direct manipulation mode, or null if none. */
+ @Nullable private View mViewInDirectManipulationMode;
+
+ public void setStartingView(@Nullable View view) {
+ mViewInDirectManipulationMode = view;
+ }
+
+ /**
+ * Returns the {@link View} from which we entered into Direct Manipulation mode when that mode
+ * is active, and null otherwise.
+ */
+ @Nullable public View getStartingView() {
+ return mViewInDirectManipulationMode;
+ }
+
+ /**
+ * Returns true if Direct Manipulation mode is active, false otherwise.
+ */
+ public boolean isActive() {
+ return mViewInDirectManipulationMode != null;
+ }
+}
diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java
index fc99676..79a384c 100644
--- a/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java
+++ b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java
@@ -23,6 +23,10 @@ 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.NonNull;
import androidx.annotation.Nullable;
@@ -30,12 +34,20 @@ import androidx.fragment.app.Fragment;
import com.android.car.ui.utils.DirectManipulationHelper;
+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},
- * {@link android.widget.RadialTimePickerView}, and {@link DirectManipulationView}.
+ * {@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.
@@ -51,24 +63,78 @@ public class RotaryDirectManipulationWidgets extends Fragment {
/** Background color of a view when it's not in direct manipulation mode. */
private static final int BACKGROUND_COLOR_NOT_IN_DIRECT_MANIPULATION_MODE = Color.TRANSPARENT;
- /** The view that is in direct manipulation mode, or null if none. */
- private View mViewInDirectManipulationMode;
+ 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);
initDirectManipulationMode(dmv, /* handleNudge= */ true, /* handleRotate= */ true);
+
+ 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 (mViewInDirectManipulationMode != null) {
+ 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).
- enableDirectManipulationMode(mViewInDirectManipulationMode, false);
+ enableDirectManipulationMode(mDirectManipulationMode.getStartingView(), false);
}
super.onPause();
}
@@ -88,12 +154,12 @@ public class RotaryDirectManipulationWidgets extends Fragment {
switch (keyCode) {
// Always consume KEYCODE_DPAD_CENTER and KEYCODE_BACK event.
case KeyEvent.KEYCODE_DPAD_CENTER:
- if (mViewInDirectManipulationMode == null && isActionUp) {
+ if (!mDirectManipulationMode.isActive() && isActionUp) {
enableDirectManipulationMode(dmv, true);
}
return true;
case KeyEvent.KEYCODE_BACK:
- if (mViewInDirectManipulationMode != null && isActionUp) {
+ if (mDirectManipulationMode.isActive() && isActionUp) {
enableDirectManipulationMode(dmv, false);
}
return true;
@@ -103,7 +169,7 @@ public class RotaryDirectManipulationWidgets extends Fragment {
case KeyEvent.KEYCODE_DPAD_DOWN:
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_DPAD_RIGHT:
- return handleNudge? handleNudgeEvent(keyEvent) : false;
+ return handleNudge ? handleNudgeEvent(keyEvent) : false;
// Don't consume other key events.
default:
return false;
@@ -125,20 +191,22 @@ public class RotaryDirectManipulationWidgets extends Fragment {
: BACKGROUND_COLOR_NOT_IN_DIRECT_MANIPULATION_MODE);
view.invalidate();
DirectManipulationHelper.enableDirectManipulationMode(view, enable);
- mViewInDirectManipulationMode = enable ? view : null;
+ View currentView = enable ? view : null;
+ mDirectManipulationMode.setStartingView(currentView);
}
/** Handles controller nudge event. Returns whether the event was consumed. */
private boolean handleNudgeEvent(KeyEvent keyEvent) {
- if (mViewInDirectManipulationMode == null) {
+ if (!mDirectManipulationMode.isActive()) {
return false;
}
if (keyEvent.getAction() != KeyEvent.ACTION_UP) {
return true;
}
int keyCode = keyEvent.getKeyCode();
- if (mViewInDirectManipulationMode instanceof DirectManipulationView) {
- DirectManipulationView dmv = (DirectManipulationView) mViewInDirectManipulationMode;
+ if (mDirectManipulationMode.getStartingView() instanceof DirectManipulationView) {
+ DirectManipulationView dmv =
+ (DirectManipulationView) mDirectManipulationMode.getStartingView();
handleNudgeEvent(dmv, keyCode);
return true;
}
@@ -150,11 +218,12 @@ public class RotaryDirectManipulationWidgets extends Fragment {
/** Handles controller rotate event. Returns whether the event was consumed. */
private boolean handleRotateEvent(float scroll) {
- if (mViewInDirectManipulationMode == null) {
+ if (!mDirectManipulationMode.isActive()) {
return false;
}
- if (mViewInDirectManipulationMode instanceof DirectManipulationView) {
- DirectManipulationView dmv = (DirectManipulationView) mViewInDirectManipulationMode;
+ if (mDirectManipulationMode.getStartingView() instanceof DirectManipulationView) {
+ DirectManipulationView dmv =
+ (DirectManipulationView) mDirectManipulationMode.getStartingView();
handleRotateEvent(dmv, scroll);
return true;
}
@@ -188,4 +257,213 @@ public class RotaryDirectManipulationWidgets extends Fragment {
private void handleRotateEvent(@NonNull DirectManipulationView dmv, float scroll) {
dmv.zoom(DIRECT_MANIPULATION_VIEW_PX_PER_ROTATION * scroll);
}
+
+ /**
+ * 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);
+ }
+ }
+ }
}