blob: 58262b6389cc57e617575b082ac328f174bbbf92 [file] [log] [blame]
/*
* Copyright (C) 2018 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 androidx.car.app;
import android.app.Dialog;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Rect;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.TouchDelegate;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.car.R;
/**
* A subclass of {@link Dialog} that is tailored for the car environment. This dialog can display a
* title text, body text, and up to two buttons -- a positive and negative button. There is no
* affordance for displaying a custom view or list of content, differentiating it from a regular
* {@code AlertDialog}.
*/
public class CarAlertDialog extends Dialog {
private final DialogData mData;
private final int mTopPadding;
private final int mBottomPadding;
private final int mButtonMinWidth;
private final int mButtonSpacing;
private View mContentView;
private TextView mTitleView;
private TextView mBodyView;
private View mButtonPanel;
private Button mPositiveButton;
private Button mNegativeButton;
private ButtonPanelTouchDelegate mButtonPanelTouchDelegate;
private CarAlertDialog(Context context, DialogData data) {
super(context, getDialogTheme(context));
mData = data;
Resources res = context.getResources();
mTopPadding = res.getDimensionPixelSize(R.dimen.car_padding_4);
mBottomPadding = res.getDimensionPixelSize(R.dimen.car_padding_4);
mButtonMinWidth = res.getDimensionPixelSize(R.dimen.car_button_min_width);
mButtonSpacing = res.getDimensionPixelSize(R.dimen.car_padding_4);
}
@Override
public void setTitle(CharSequence title) {
// Ideally this method should be private; the dialog should only be modifiable through the
// Builder. Unfortunately, this method is defined with the Dialog itself and is public.
// So, throw an error if this method is ever called. setTitleInternal() should be used
// to set the title within this class.
throw new UnsupportedOperationException("Title should only be set from the Builder");
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().setContentView(R.layout.car_alert_dialog);
initializeViews();
initializeDialogWithData();
}
private void setTitleInternal(CharSequence title) {
boolean hasTitle = !TextUtils.isEmpty(title);
boolean hasBody = mBodyView.getVisibility() == View.VISIBLE;
boolean hasButton = mButtonPanel.getVisibility() == View.VISIBLE;
mTitleView.setText(title);
mTitleView.setVisibility(hasTitle ? View.VISIBLE : View.GONE);
// Center title if there is no button.
mTitleView.setGravity(hasButton ? Gravity.CENTER_VERTICAL | Gravity.START : Gravity.CENTER);
// If there's a title, then remove the padding at the top of the content view.
int topPadding = hasTitle ? 0 : mTopPadding;
// If there is only title, also remove the padding at the bottom so title is centered.
int bottomPadding = !hasButton && !hasBody ? 0 : mContentView.getPaddingBottom();
mContentView.setPaddingRelative(
mContentView.getPaddingStart(),
topPadding,
mContentView.getPaddingEnd(),
bottomPadding);
}
private void setBody(CharSequence body) {
mBodyView.setText(body);
mBodyView.setVisibility(TextUtils.isEmpty(body) ? View.GONE : View.VISIBLE);
}
private void setPositiveButton(CharSequence text) {
boolean showButton = !TextUtils.isEmpty(text);
mPositiveButton.setText(text);
mPositiveButton.setVisibility(showButton ? View.VISIBLE : View.GONE);
updateTargetTargetForButton(mPositiveButton);
updateButtonPanelVisibility();
updateButtonSpacing();
}
private void setNegativeButton(CharSequence text) {
mNegativeButton.setText(text);
mNegativeButton.setVisibility(TextUtils.isEmpty(text) ? View.GONE : View.VISIBLE);
updateTargetTargetForButton(mNegativeButton);
updateButtonPanelVisibility();
updateButtonSpacing();
}
/**
* Checks if the given view that represents a positive or negative button currently meets the
* minimum touch target size that is dictated by {@link #mButtonMinWidth}. If it does not, then
* this method will utilize a {@link TouchDelegate} to expand the touch target size of that
* button.
*
* @param button One of {@link #mPositiveButton} or {@link #mNegativeButton}.
*/
private void updateTargetTargetForButton(View button) {
if (button != mPositiveButton && button != mNegativeButton) {
throw new IllegalArgumentException("Method must be passed one of mPositiveButton or "
+ "mNegativeButton");
}
if (button.getVisibility() != View.VISIBLE) {
return;
}
// The TouchDelegate needs to be set after the panel has been laid out in order to get the
// hit Rect.
mButtonPanel.post(() -> {
Rect rect = new Rect();
button.getHitRect(rect);
int hitWidth = Math.abs(rect.right - rect.left);
TouchDelegate touchDelegate = null;
// If the button does not meet the minimum requirements for touch target size, then
// expand its hit area with a TouchDelegate.
if (hitWidth < mButtonMinWidth) {
int amountToIncrease = (mButtonMinWidth - hitWidth) / 2;
rect.left -= amountToIncrease;
rect.right += amountToIncrease;
touchDelegate = new TouchDelegate(rect, button);
}
if (button == mPositiveButton) {
mButtonPanelTouchDelegate.setPositiveButtonDelegate(touchDelegate);
} else {
mButtonPanelTouchDelegate.setNegativeButtonDelegate(touchDelegate);
}
});
}
/**
* Checks if spacing should be added between the positive and negative button. The spacing is
* only needed if both buttons are visible.
*/
private void updateButtonSpacing() {
int marginEnd;
// If both buttons are visible, then there needs to be spacing between them.
if ((mPositiveButton.getVisibility() == View.VISIBLE
&& mNegativeButton.getVisibility() == View.VISIBLE)) {
marginEnd = mButtonSpacing;
} else {
marginEnd = 0;
}
ViewGroup.MarginLayoutParams layoutParams =
(ViewGroup.MarginLayoutParams) mPositiveButton.getLayoutParams();
layoutParams.setMarginEnd(marginEnd);
mPositiveButton.requestLayout();
}
/**
* Toggles whether or not the panel containing the action buttons are visible depending on if
* a button should be shown.
*/
private void updateButtonPanelVisibility() {
boolean hasButtons = mPositiveButton.getVisibility() == View.VISIBLE
|| mNegativeButton.getVisibility() == View.VISIBLE;
int visibility = hasButtons ? View.VISIBLE : View.GONE;
// Visibility is already correct, so nothing further needs to be done.
if (mButtonPanel.getVisibility() == visibility) {
return;
}
mButtonPanel.setVisibility(visibility);
// If there are buttons, then remove the padding at the bottom of the content view.
int buttonPadding = hasButtons ? 0 : mBottomPadding;
mContentView.setPaddingRelative(
mContentView.getPaddingStart(),
mContentView.getPaddingTop(),
mContentView.getPaddingEnd(),
buttonPadding);
}
/**
* Looks through the {@link DialogData} that was passed to this dialog and initialize its
* contents based on what data is present.
*/
private void initializeDialogWithData() {
setBody(mData.mBody);
setPositiveButton(mData.mPositiveButtonText);
setNegativeButton(mData.mNegativeButtonText);
// setTitleInternal() should be called last because we want to center title and adjust
// padding depending on body/button configuration.
setTitleInternal(mData.mTitle);
}
/**
* Initializes the views within the dialog that are modifiable based on the data that has been
* set on it. Also responsible for hooking up listeners for button clicks.
*/
private void initializeViews() {
Window window = getWindow();
mContentView = window.findViewById(R.id.content_view);
mTitleView = window.findViewById(R.id.title);
mBodyView = window.findViewById(R.id.body);
mButtonPanel = window.findViewById(R.id.button_panel);
mButtonPanelTouchDelegate = new ButtonPanelTouchDelegate(mButtonPanel);
mButtonPanel.setTouchDelegate(mButtonPanelTouchDelegate);
mPositiveButton = window.findViewById(R.id.positive_button);
mNegativeButton = window.findViewById(R.id.negative_button);
mPositiveButton.setOnClickListener(v -> onPositiveButtonClick());
mNegativeButton.setOnClickListener(v -> onNegativeButtonClick());
}
/** Delegates to a listener on the positive button if it exists or dismisses the dialog. */
private void onPositiveButtonClick() {
if (mData.mPositiveButtonListener != null) {
mData.mPositiveButtonListener.onClick(this /* dialog */, BUTTON_POSITIVE);
} else {
dismiss();
}
}
/** Delegates to a listener on the negative button if it exists or dismisses the dialog. */
private void onNegativeButtonClick() {
if (mData.mNegativeButtonListener != null) {
mData.mNegativeButtonListener.onClick(this /* dialog */, BUTTON_NEGATIVE);
} else {
dismiss();
}
}
/**
* A composite {@link TouchDelegate} for a button panel that has two buttons. It can hold
* multiple {@code TouchDelegate}s and will delegate out touch events to each.
*/
private static final class ButtonPanelTouchDelegate extends TouchDelegate {
@Nullable private TouchDelegate mPositiveButtonDelegate;
@Nullable private TouchDelegate mNegativeButtonDelegate;
ButtonPanelTouchDelegate(View view) {
super(new Rect(), view);
}
public void setPositiveButtonDelegate(@Nullable TouchDelegate delegate) {
mPositiveButtonDelegate = delegate;
}
public void setNegativeButtonDelegate(@Nullable TouchDelegate delegate) {
mNegativeButtonDelegate = delegate;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean result = false;
float x = event.getX();
float y = event.getY();
event.setLocation(x, y);
if (mPositiveButtonDelegate != null) {
result = mPositiveButtonDelegate.onTouchEvent(event);
}
if (mNegativeButtonDelegate != null) {
result |= mNegativeButtonDelegate.onTouchEvent(event);
}
return result;
}
}
/**
* Returns the style that has been assigned to {@code carDialogTheme} in the
* current theme that is inflating this dialog.
*/
private static int getDialogTheme(Context context) {
TypedValue outValue = new TypedValue();
context.getTheme().resolveAttribute(R.attr.carDialogTheme, outValue, true);
return outValue.resourceId;
}
/**
* A class that holds the data that is settable by the {@link Builder} and should be displayed
* in the {@link CarAlertDialog}.
*/
private static class DialogData {
private CharSequence mTitle;
private CharSequence mBody;
private CharSequence mPositiveButtonText;
private OnClickListener mPositiveButtonListener;
private CharSequence mNegativeButtonText;
private OnClickListener mNegativeButtonListener;
}
/**
* Builder class that can be used to create a {@link CarAlertDialog} by configuring the options
* for what shows up in the resulting dialog.
*/
public static final class Builder {
private final Context mContext;
private final DialogData mDialogData;
private boolean mCancelable = true;
private OnCancelListener mOnCancelListener;
private OnDismissListener mOnDismissListener;
/**
* Creates a new instance of the {@code Builder}.
*
* @param context The {@code Context} that the dialog is to be created in.
*/
public Builder(Context context) {
mContext = context;
mDialogData = new DialogData();
}
/**
* Sets the main title of the dialog to be the given string resource.
*
* @param titleId The resource id of the string to be used as the title.
* @return This {@code Builder} object to allow for chaining of calls.
*/
public Builder setTitle(@StringRes int titleId) {
mDialogData.mTitle = mContext.getString(titleId);
return this;
}
/**
* Sets the main title of the dialog for be the given string.
*
* @param title The string to be used as the title.
* @return This {@code Builder} object to allow for chaining of calls.
*/
public Builder setTitle(CharSequence title) {
mDialogData.mTitle = title;
return this;
}
/**
* Sets the body text of the dialog to be the given string resource.
*
* @param bodyId The resource id of the string to be used as the body text.
* @return This {@code Builder} object to allow for chaining of calls.
*/
public Builder setBody(@StringRes int bodyId) {
mDialogData.mBody = mContext.getString(bodyId);
return this;
}
/**
* Sets the body text of the dialog to be the given string.
*
* @param body The string to be used as the body text.
* @return This {@code Builder} object to allow for chaining of calls.
*/
public Builder setBody(CharSequence body) {
mDialogData.mBody = body;
return this;
}
/**
* Sets the text of the positive button and the listener that will be invoked when the
* button is pressed. If a listener is not provided, then the dialog will dismiss itself
* when the positive button is clicked.
*
* <p>The positive button should be used to accept and continue with the action (e.g.
* an "OK" action).
*
* @param textId The resource id of the string to be used for the positive button text.
* @param listener A {@link android.content.DialogInterface.OnClickListener} to be invoked
* when the button is clicked. Can be {@code null} to represent no listener.
* @return This {@code Builder} object to allow for chaining of calls.
*/
public Builder setPositiveButton(@StringRes int textId,
@Nullable OnClickListener listener) {
mDialogData.mPositiveButtonText = mContext.getString(textId);
mDialogData.mPositiveButtonListener = listener;
return this;
}
/**
* Sets the text of the positive button and the listener that will be invoked when the
* button is pressed. If a listener is not provided, then the dialog will dismiss itself
* when the positive button is clicked.
*
* <p>The positive button should be used to accept and continue with the action (e.g.
* an "OK" action).
*
* @param text The string to be used for the positive button text.
* @param listener A {@link android.content.DialogInterface.OnClickListener} to be invoked
* when the button is clicked. Can be {@code null} to represent no listener.
* @return This {@code Builder} object to allow for chaining of calls.
*/
public Builder setPositiveButton(CharSequence text, @Nullable OnClickListener listener) {
mDialogData.mPositiveButtonText = text;
mDialogData.mPositiveButtonListener = listener;
return this;
}
/**
* Sets the text of the negative button and the listener that will be invoked when the
* button is pressed. If a listener is not provided, then the dialog will dismiss itself
* when the negative button is clicked.
*
* <p>The negative button should be used to cancel any actions the dialog represents.
*
* @param textId The resource id of the string to be used for the negative button text.
* @param listener A {@link android.content.DialogInterface.OnClickListener} to be invoked
* when the button is clicked. Can be {@code null} to represent no listener.
* @return This {@code Builder} object to allow for chaining of calls.
*/
public Builder setNegativeButton(@StringRes int textId,
@Nullable OnClickListener listener) {
mDialogData.mNegativeButtonText = mContext.getString(textId);
mDialogData.mNegativeButtonListener = listener;
return this;
}
/**
* Sets the text of the negative button and the listener that will be invoked when the
* button is pressed. If a listener is not provided, then the dialog will dismiss itself
* when the negative button is clicked.
*
* <p>The negative button should be used to cancel any actions the dialog represents.
*
* @param text The string to be used for the negative button text.
* @param listener A {@link android.content.DialogInterface.OnClickListener} to be invoked
* when the button is clicked. Can be {@code null} to represent no listener.
* @return This {@code Builder} object to allow for chaining of calls.
*/
public Builder setNegativeButton(CharSequence text, @Nullable OnClickListener listener) {
mDialogData.mNegativeButtonText = text;
mDialogData.mNegativeButtonListener = listener;
return this;
}
/**
* Sets whether the dialog is cancelable or not. Default is {@code true}.
*
* @return This {@code Builder} object to allow for chaining of calls.
*/
public Builder setCancelable(boolean cancelable) {
mCancelable = cancelable;
return this;
}
/**
* Sets the callback that will be called if the dialog is canceled.
*
* <p>Even in a cancelable dialog, the dialog may be dismissed for reasons other than
* being canceled or one of the supplied choices being selected.
* If you are interested in listening for all cases where the dialog is dismissed
* and not just when it is canceled, see {@link #setOnDismissListener(OnDismissListener)}.
*
* @param onCancelListener The listener to be invoked when this dialog is canceled.
* @return This {@code Builder} object to allow for chaining of calls.
*
* @see #setCancelable(boolean)
* @see #setOnDismissListener(OnDismissListener)
*/
public Builder setOnCancelListener(OnCancelListener onCancelListener) {
mOnCancelListener = onCancelListener;
return this;
}
/**
* Sets the callback that will be called when the dialog is dismissed for any reason.
*
* @return This {@code Builder} object to allow for chaining of calls.
*/
public Builder setOnDismissListener(OnDismissListener onDismissListener) {
mOnDismissListener = onDismissListener;
return this;
}
/**
* Creates an {@link CarAlertDialog} with the arguments supplied to this {@code Builder}.
*
* <p>Calling this method does not display the dialog. Utilize this dialog within a
* {@link androidx.fragment.app.DialogFragment} to show the dialog.
*/
public CarAlertDialog create() {
CarAlertDialog dialog = new CarAlertDialog(mContext, mDialogData);
dialog.setCancelable(mCancelable);
dialog.setCanceledOnTouchOutside(mCancelable);
dialog.setOnCancelListener(mOnCancelListener);
dialog.setOnDismissListener(mOnDismissListener);
return dialog;
}
}
}