blob: 14d4b1966c786c94f8cf1c24fdc33fd0e00e3622 [file] [log] [blame]
/*
* Copyright (C) 2021 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.qc.view;
import static com.android.car.qc.QCItem.QC_ACTION_SLIDER_VALUE;
import static com.android.car.qc.QCItem.QC_ACTION_TOGGLE_STATE;
import static com.android.car.qc.QCItem.QC_TYPE_ACTION_SWITCH;
import static com.android.car.qc.view.QCView.QCActionListener;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.text.BidiFormatter;
import android.text.TextDirectionHeuristics;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.SeekBar;
import android.widget.Switch;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.car.qc.QCActionItem;
import com.android.car.qc.QCItem;
import com.android.car.qc.QCRow;
import com.android.car.qc.QCSlider;
import com.android.car.qc.R;
import com.android.car.ui.utils.DirectManipulationHelper;
import com.android.car.ui.uxr.DrawableStateToggleButton;
/**
* Quick Controls view for {@link QCRow} instances.
*/
public class QCRowView extends FrameLayout {
private static final String TAG = "QCRowView";
private LayoutInflater mLayoutInflater;
private BidiFormatter mBidiFormatter;
private View mContentView;
private TextView mTitle;
private TextView mSubtitle;
private ImageView mStartIcon;
@ColorInt
private int mStartIconTint;
private LinearLayout mStartItemsContainer;
private LinearLayout mEndItemsContainer;
private LinearLayout mSeekBarContainer;
private SeekBar mSeekBar;
private QCActionListener mActionListener;
private boolean mInDirectManipulationMode;
private QCSeekbarChangeListener mSeekbarChangeListener;
private final View.OnKeyListener mSeekBarKeyListener = new View.OnKeyListener() {
@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {
if (mSeekBar == null) {
return false;
}
// Consume nudge events in direct manipulation mode.
if (mInDirectManipulationMode
&& (keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|| keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
|| keyCode == KeyEvent.KEYCODE_DPAD_UP
|| keyCode == KeyEvent.KEYCODE_DPAD_DOWN)) {
return true;
}
// Handle events to enter or exit direct manipulation mode.
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
setInDirectManipulationMode(v, mSeekBar, !mInDirectManipulationMode);
}
return true;
}
if (keyCode == KeyEvent.KEYCODE_BACK) {
if (mInDirectManipulationMode) {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
setInDirectManipulationMode(v, mSeekBar, false);
}
return true;
}
}
// Don't propagate confirm keys to the SeekBar to prevent a ripple effect on the thumb.
if (KeyEvent.isConfirmKey(keyCode)) {
return false;
}
if (event.getAction() == KeyEvent.ACTION_DOWN) {
return mSeekBar.onKeyDown(keyCode, event);
} else {
return mSeekBar.onKeyUp(keyCode, event);
}
}
};
private final View.OnFocusChangeListener mSeekBarFocusChangeListener =
(v, hasFocus) -> {
if (!hasFocus && mInDirectManipulationMode && mSeekBar != null) {
setInDirectManipulationMode(v, mSeekBar, false);
}
};
private final View.OnGenericMotionListener mSeekBarScrollListener =
(v, event) -> {
if (!mInDirectManipulationMode || mSeekBar == null) {
return false;
}
int adjustment = Math.round(event.getAxisValue(MotionEvent.AXIS_SCROLL));
if (adjustment == 0) {
return false;
}
int count = Math.abs(adjustment);
int keyCode =
adjustment < 0 ? KeyEvent.KEYCODE_DPAD_LEFT : KeyEvent.KEYCODE_DPAD_RIGHT;
KeyEvent downEvent = new KeyEvent(event.getDownTime(), event.getEventTime(),
KeyEvent.ACTION_DOWN, keyCode, /* repeat= */ 0);
KeyEvent upEvent = new KeyEvent(event.getDownTime(), event.getEventTime(),
KeyEvent.ACTION_UP, keyCode, /* repeat= */ 0);
for (int i = 0; i < count; i++) {
mSeekBar.onKeyDown(keyCode, downEvent);
mSeekBar.onKeyUp(keyCode, upEvent);
}
return true;
};
QCRowView(Context context) {
super(context);
init(context);
}
QCRowView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
QCRowView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
QCRowView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context);
}
private void init(Context context) {
mLayoutInflater = LayoutInflater.from(context);
mBidiFormatter = BidiFormatter.getInstance();
mLayoutInflater.inflate(R.layout.qc_row_view, /* root= */ this);
mContentView = findViewById(R.id.qc_row_content);
mTitle = findViewById(R.id.qc_title);
mSubtitle = findViewById(R.id.qc_summary);
mStartIcon = findViewById(R.id.qc_icon);
mStartItemsContainer = findViewById(R.id.qc_row_start_items);
mEndItemsContainer = findViewById(R.id.qc_row_end_items);
mSeekBarContainer = findViewById(R.id.qc_seekbar_wrapper);
mSeekBar = findViewById(R.id.seekbar);
}
void setActionListener(QCActionListener listener) {
mActionListener = listener;
}
void setRow(QCRow row) {
if (row == null) {
setVisibility(GONE);
return;
}
setVisibility(VISIBLE);
if (row.getPrimaryAction() != null || row.getActionHandler() != null) {
mContentView.setOnClickListener(v -> {
fireAction(row, /* intent= */ null);
});
}
if (!TextUtils.isEmpty(row.getTitle())) {
mTitle.setVisibility(VISIBLE);
mTitle.setText(
mBidiFormatter.unicodeWrap(row.getTitle(), TextDirectionHeuristics.LOCALE));
} else {
mTitle.setVisibility(GONE);
}
if (!TextUtils.isEmpty(row.getSubtitle())) {
mSubtitle.setVisibility(VISIBLE);
mSubtitle.setText(
mBidiFormatter.unicodeWrap(row.getSubtitle(), TextDirectionHeuristics.LOCALE));
} else {
mSubtitle.setVisibility(GONE);
}
if (row.getStartIcon() != null) {
mStartIcon.setVisibility(VISIBLE);
Drawable drawable = row.getStartIcon().loadDrawable(getContext());
if (drawable != null && row.isStartIconTintable()) {
if (mStartIconTint == 0) {
mStartIconTint = getContext().getColor(R.color.qc_start_icon_color);
}
drawable.setTint(mStartIconTint);
}
mStartIcon.setImageDrawable(drawable);
} else {
mStartIcon.setImageDrawable(null);
mStartIcon.setVisibility(GONE);
}
QCSlider slider = row.getSlider();
if (slider != null) {
mSeekBarContainer.setVisibility(View.VISIBLE);
initSlider(slider);
} else {
mSeekBarContainer.setVisibility(View.GONE);
}
int startItemCount = row.getStartItems().size();
for (int i = 0; i < startItemCount; i++) {
QCActionItem action = row.getStartItems().get(i);
initActionItem(mStartItemsContainer, mStartItemsContainer.getChildAt(i), action);
}
if (mStartItemsContainer.getChildCount() > startItemCount) {
// remove extra items
mStartItemsContainer.removeViews(startItemCount,
mStartItemsContainer.getChildCount() - startItemCount);
}
if (startItemCount == 0) {
mStartItemsContainer.setVisibility(View.GONE);
} else {
mStartItemsContainer.setVisibility(View.VISIBLE);
}
int endItemCount = row.getEndItems().size();
for (int i = 0; i < endItemCount; i++) {
QCActionItem action = row.getEndItems().get(i);
initActionItem(mEndItemsContainer, mEndItemsContainer.getChildAt(i), action);
}
if (mEndItemsContainer.getChildCount() > endItemCount) {
// remove extra items
mEndItemsContainer.removeViews(endItemCount,
mEndItemsContainer.getChildCount() - endItemCount);
}
if (endItemCount == 0) {
mEndItemsContainer.setVisibility(View.GONE);
} else {
mEndItemsContainer.setVisibility(View.VISIBLE);
}
}
private void initActionItem(@NonNull ViewGroup root, @Nullable View actionView,
@NonNull QCActionItem action) {
if (action.getType().equals(QC_TYPE_ACTION_SWITCH)) {
initSwitchView(action, root, actionView);
} else {
initToggleView(action, root, actionView);
}
}
private void initSwitchView(QCActionItem action, ViewGroup root, View actionView) {
Switch switchView = actionView == null ? null : actionView.findViewById(
android.R.id.switch_widget);
if (switchView == null) {
actionView = createActionView(root, actionView, R.layout.qc_action_switch);
switchView = actionView.requireViewById(android.R.id.switch_widget);
}
switchView.setOnCheckedChangeListener(null);
switchView.setEnabled(action.isEnabled());
switchView.setChecked(action.isChecked());
switchView.setOnCheckedChangeListener(
(buttonView, isChecked) -> {
Intent intent = new Intent();
intent.putExtra(QC_ACTION_TOGGLE_STATE, isChecked);
fireAction(action, intent);
});
}
private void initToggleView(QCActionItem action, ViewGroup root, View actionView) {
DrawableStateToggleButton toggleButton =
actionView == null ? null : actionView.findViewById(R.id.qc_toggle_button);
if (toggleButton == null) {
actionView = createActionView(root, actionView, R.layout.qc_action_toggle);
toggleButton = actionView.requireViewById(R.id.qc_toggle_button);
}
toggleButton.setText(null);
toggleButton.setTextOn(null);
toggleButton.setTextOff(null);
toggleButton.setOnCheckedChangeListener(null);
Drawable icon = QCViewUtils.getInstance(mContext).getToggleIcon(
action.getIcon(), action.isAvailable());
toggleButton.setButtonDrawable(icon);
toggleButton.setChecked(action.isChecked());
toggleButton.setEnabled(action.isEnabled() && action.isAvailable());
toggleButton.setOnCheckedChangeListener(
(buttonView, isChecked) -> {
Intent intent = new Intent();
intent.putExtra(QC_ACTION_TOGGLE_STATE, isChecked);
fireAction(action, intent);
});
}
@NonNull
private View createActionView(@NonNull ViewGroup root, @Nullable View actionView,
@LayoutRes int resId) {
if (actionView != null) {
// remove current action view
root.removeView(actionView);
}
actionView = mLayoutInflater.inflate(resId, /* root = */ null);
root.addView(actionView);
return actionView;
}
private void initSlider(QCSlider slider) {
mSeekBar.setOnSeekBarChangeListener(null);
mSeekBar.setMin(slider.getMin());
mSeekBar.setMax(slider.getMax());
mSeekBar.setProgress(slider.getValue());
if (mSeekbarChangeListener == null) {
mSeekbarChangeListener = new QCSeekbarChangeListener();
}
mSeekbarChangeListener.setSlider(slider);
mSeekBar.setOnSeekBarChangeListener(mSeekbarChangeListener);
// set up rotary support
mSeekBarContainer.setOnKeyListener(mSeekBarKeyListener);
mSeekBarContainer.setOnFocusChangeListener(mSeekBarFocusChangeListener);
mSeekBarContainer.setOnGenericMotionListener(mSeekBarScrollListener);
}
private void setInDirectManipulationMode(View view, SeekBar seekbar, boolean enable) {
mInDirectManipulationMode = enable;
DirectManipulationHelper.enableDirectManipulationMode(seekbar, enable);
view.setSelected(enable);
seekbar.setSelected(enable);
}
private void fireAction(QCItem item, Intent intent) {
if (item.getPrimaryAction() != null) {
try {
item.getPrimaryAction().send(getContext(), 0, intent);
if (mActionListener != null) {
mActionListener.onQCAction(item);
}
} catch (PendingIntent.CanceledException e) {
Log.d(TAG, "Error sending intent", e);
}
} else if (item.getActionHandler() != null) {
item.getActionHandler().onAction(item, getContext(), intent);
if (mActionListener != null) {
mActionListener.onQCAction(item);
}
}
}
private class QCSeekbarChangeListener implements SeekBar.OnSeekBarChangeListener {
// Interval of updates (in ms) sent in response to seekbar moving.
private static final int SLIDER_UPDATE_INTERVAL = 200;
private final Handler mSliderUpdateHandler;
private QCSlider mSlider;
private int mCurrSliderValue;
private boolean mSliderUpdaterRunning;
private long mLastSentSliderUpdate;
private final Runnable mSliderUpdater = () -> {
sendSliderValue();
mSliderUpdaterRunning = false;
};
QCSeekbarChangeListener() {
mSliderUpdateHandler = new Handler();
}
void setSlider(QCSlider slider) {
mSlider = slider;
}
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
mCurrSliderValue = progress;
long now = System.currentTimeMillis();
if (mLastSentSliderUpdate != 0
&& now - mLastSentSliderUpdate > SLIDER_UPDATE_INTERVAL) {
mSliderUpdaterRunning = false;
mSliderUpdateHandler.removeCallbacks(mSliderUpdater);
sendSliderValue();
} else if (!mSliderUpdaterRunning) {
mSliderUpdaterRunning = true;
mSliderUpdateHandler.postDelayed(mSliderUpdater, SLIDER_UPDATE_INTERVAL);
}
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
if (mSliderUpdaterRunning) {
mSliderUpdaterRunning = false;
mSliderUpdateHandler.removeCallbacks(mSliderUpdater);
}
mCurrSliderValue = seekBar.getProgress();
sendSliderValue();
}
private void sendSliderValue() {
if (mSlider == null) {
return;
}
mLastSentSliderUpdate = System.currentTimeMillis();
Intent intent = new Intent();
intent.putExtra(QC_ACTION_SLIDER_VALUE, mCurrSliderValue);
fireAction(mSlider, intent);
}
}
}