blob: 855bded5dc72387489abaf8eec8ed2124958d0e1 [file] [log] [blame]
/*
* Copyright 2017 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.slice.widget;
import static android.app.slice.Slice.SUBTYPE_COLOR;
import static android.app.slice.SliceItem.FORMAT_INT;
import android.app.PendingIntent;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.ColorDrawable;
import android.os.Handler;
import android.util.AttributeSet;
import android.util.Log;
import android.view.HapticFeedbackConstants;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RestrictTo;
import androidx.lifecycle.Observer;
import androidx.slice.Slice;
import androidx.slice.SliceItem;
import androidx.slice.SliceMetadata;
import androidx.slice.core.SliceActionImpl;
import androidx.slice.core.SliceHints;
import androidx.slice.core.SliceQuery;
import androidx.slice.view.R;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.List;
/**
* A view for displaying a {@link Slice} which is a piece of app content and actions. SliceView is
* able to present slice content in a templated format outside of the associated app. The way this
* content is displayed depends on the structure of the slice, the hints associated with the
* content, and the mode that SliceView is configured for. The modes that SliceView supports are:
* <ul>
* <li><b>Shortcut</b>: A shortcut is presented as an icon and a text label representing the main
* content or action associated with the slice.</li>
* <li><b>Small</b>: The small format has a restricted height and can present a single
* {@link SliceItem} or a limited collection of items.</li>
* <li><b>Large</b>: The large format displays multiple small templates in a list, if scrolling is
* not enabled (see {@link #setScrollable(boolean)}) the view will show as many items as it can
* comfortably fit.</li>
* </ul>
* <p>
* When constructing a slice, the contents of it can be annotated with hints, these provide the OS
* with some information on how the content should be displayed. For example, text annotated with
* {@link android.app.slice.Slice#HINT_TITLE} would be placed in the title position of a template.
* A slice annotated with {@link android.app.slice.Slice#HINT_LIST} would present the child items
* of that slice in a list.
* <p>
* Example usage:
*
* <pre class="prettyprint">
* SliceView v = new SliceView(getContext());
* v.setMode(desiredMode);
* LiveData<Slice> liveData = SliceLiveData.fromUri(sliceUri);
* liveData.observe(lifecycleOwner, v);
* </pre>
* @see SliceLiveData
*/
public class SliceView extends ViewGroup implements Observer<Slice>, View.OnClickListener {
private static final String TAG = "SliceView";
/**
* Implement this interface to be notified of interactions with the slice displayed
* in this view.
* @see EventInfo
*/
public interface OnSliceActionListener {
/**
* Called when an interaction has occurred with an element in this view.
* @param info the type of event that occurred.
* @param item the specific item within the {@link Slice} that was interacted with.
*/
void onSliceAction(@NonNull EventInfo info, @NonNull SliceItem item);
}
/**
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
@IntDef({
MODE_SMALL, MODE_LARGE, MODE_SHORTCUT
})
@Retention(RetentionPolicy.SOURCE)
public @interface SliceMode {}
/**
* Mode indicating this slice should be presented in small template format.
*/
public static final int MODE_SMALL = 1;
/**
* Mode indicating this slice should be presented in large template format.
*/
public static final int MODE_LARGE = 2;
/**
* Mode indicating this slice should be presented as an icon. A shortcut requires an intent,
* icon, and label. This can be indicated by using {@link android.app.slice.Slice#HINT_TITLE}
* on an action in a slice.
*/
public static final int MODE_SHORTCUT = 3;
private int mMode = MODE_LARGE;
private Slice mCurrentSlice;
private ListContent mListContent;
private SliceChildView mCurrentView;
private List<SliceItem> mActions;
private ActionRow mActionRow;
private boolean mShowActions = false;
private boolean mIsScrollable = true;
private boolean mShowLastUpdated = true;
private int mShortcutSize;
private int mMinLargeHeight;
private int mMaxLargeHeight;
private int mActionRowHeight;
private AttributeSet mAttrs;
private int mDefStyleAttr;
private int mDefStyleRes;
private int mThemeTintColor = -1;
private OnSliceActionListener mSliceObserver;
private int mTouchSlopSquared;
private View.OnLongClickListener mLongClickListener;
private View.OnClickListener mOnClickListener;
private int mDownX;
private int mDownY;
private boolean mPressing;
private boolean mInLongpress;
private Handler mHandler;
int[] mClickInfo;
public SliceView(Context context) {
this(context, null);
}
public SliceView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, R.attr.sliceViewStyle);
}
public SliceView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs, defStyleAttr, R.style.Widget_SliceView);
}
@RequiresApi(21)
public SliceView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context, attrs, defStyleAttr, defStyleRes);
}
private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
mAttrs = attrs;
mDefStyleAttr = defStyleAttr;
mDefStyleRes = defStyleRes;
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SliceView,
defStyleAttr, defStyleRes);
try {
mThemeTintColor = a.getColor(R.styleable.SliceView_tintColor, -1);
} finally {
a.recycle();
}
mShortcutSize = getContext().getResources()
.getDimensionPixelSize(R.dimen.abc_slice_shortcut_size);
mMinLargeHeight = getResources().getDimensionPixelSize(R.dimen.abc_slice_large_height);
mMaxLargeHeight = getResources().getDimensionPixelSize(R.dimen.abc_slice_max_large_height);
mActionRowHeight = getResources().getDimensionPixelSize(
R.dimen.abc_slice_action_row_height);
mCurrentView = new LargeTemplateView(getContext());
mCurrentView.setMode(getMode());
addView(mCurrentView, getChildLp(mCurrentView));
// TODO: action row background should support light / dark / maybe presenter customization
mActionRow = new ActionRow(getContext(), true);
mActionRow.setBackground(new ColorDrawable(0xffeeeeee));
addView(mActionRow, getChildLp(mActionRow));
final int slop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
mTouchSlopSquared = slop * slop;
mHandler = new Handler();
super.setOnClickListener(this);
}
/**
* Indicates whether this view reacts to click events or not.
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
public boolean isSliceViewClickable() {
return mOnClickListener != null
|| (mListContent != null && mListContent.getPrimaryAction() != null);
}
/**
* Sets the event info for logging a click.
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
public void setClickInfo(int[] info) {
mClickInfo = info;
}
@Override
public void onClick(View v) {
if (mListContent != null && mListContent.getPrimaryAction() != null) {
try {
SliceActionImpl sa = new SliceActionImpl(mListContent.getPrimaryAction());
sa.getAction().send();
if (mSliceObserver != null && mClickInfo != null && mClickInfo.length > 1) {
EventInfo eventInfo = new EventInfo(getMode(),
EventInfo.ACTION_TYPE_CONTENT, mClickInfo[0], mClickInfo[1]);
mSliceObserver.onSliceAction(eventInfo, mListContent.getPrimaryAction());
}
} catch (PendingIntent.CanceledException e) {
Log.e(TAG, "PendingIntent for slice cannot be sent", e);
}
} else if (mOnClickListener != null) {
mOnClickListener.onClick(this);
}
}
@Override
public void setOnClickListener(View.OnClickListener listener) {
mOnClickListener = listener;
}
@Override
public void setOnLongClickListener(View.OnLongClickListener listener) {
super.setOnLongClickListener(listener);
mLongClickListener = listener;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean ret = super.onInterceptTouchEvent(ev);
if (mLongClickListener != null) {
return handleTouchForLongpress(ev);
}
return ret;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
boolean ret = super.onTouchEvent(ev);
if (mLongClickListener != null) {
return handleTouchForLongpress(ev);
}
return ret;
}
private boolean handleTouchForLongpress(MotionEvent ev) {
int action = ev.getActionMasked();
switch (action) {
case MotionEvent.ACTION_DOWN:
mHandler.removeCallbacks(mLongpressCheck);
mDownX = (int) ev.getRawX();
mDownY = (int) ev.getRawY();
mPressing = true;
mInLongpress = false;
mHandler.postDelayed(mLongpressCheck, ViewConfiguration.getLongPressTimeout());
break;
case MotionEvent.ACTION_MOVE:
final int deltaX = (int) ev.getRawX() - mDownX;
final int deltaY = (int) ev.getRawY() - mDownY;
int distance = (deltaX * deltaX) + (deltaY * deltaY);
if (distance > mTouchSlopSquared) {
mPressing = false;
mHandler.removeCallbacks(mLongpressCheck);
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mPressing = false;
mInLongpress = false;
mHandler.removeCallbacks(mLongpressCheck);
break;
}
return mInLongpress;
}
private int getHeightForMode() {
int mode = getMode();
if (mode == MODE_SHORTCUT) {
return mShortcutSize;
}
return mode == MODE_LARGE
? mCurrentView.getActualHeight()
: mCurrentView.getSmallHeight();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int width = MeasureSpec.getSize(widthMeasureSpec);
int childWidth = MeasureSpec.getSize(widthMeasureSpec);
if (MODE_SHORTCUT == mMode) {
// TODO: consider scaling the shortcut to fit if too small
childWidth = mShortcutSize;
width = mShortcutSize + getPaddingLeft() + getPaddingRight();
}
final int actionHeight = mActionRow.getVisibility() != View.GONE
? mActionRowHeight
: 0;
final int sliceHeight = getHeightForMode();
final int heightAvailable = MeasureSpec.getSize(heightMeasureSpec);
final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
// Remove the padding from our available height
int height = heightAvailable - getPaddingTop() - getPaddingBottom();
if (heightAvailable >= sliceHeight + actionHeight
|| heightMode == MeasureSpec.UNSPECIFIED) {
// Available space is larger than the slice or we be what we want
if (heightMode != MeasureSpec.EXACTLY) {
if (!mIsScrollable) {
height = Math.min(mMaxLargeHeight, sliceHeight);
} else {
// If we want to be bigger than max, then we can be a good scrollable at min
// large height, if it's not larger lets just use its desired height
height = sliceHeight > mMaxLargeHeight ? mMinLargeHeight : sliceHeight;
}
}
} else {
// Not enough space available for slice in current mode
if (getMode() == MODE_LARGE && heightAvailable >= mMinLargeHeight + actionHeight) {
// It's just a slice with scrolling content; cap it to height available.
height = Math.min(mMinLargeHeight, heightAvailable);
} else if (getMode() == MODE_SHORTCUT) {
// TODO: consider scaling the shortcut to fit if too small
height = mShortcutSize;
}
}
int childHeight = height + getPaddingTop() + getPaddingBottom();
int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY);
int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY);
measureChild(mCurrentView, childWidthMeasureSpec, childHeightMeasureSpec);
int actionPaddedHeight = actionHeight + getPaddingTop() + getPaddingBottom();
int actionHeightSpec = MeasureSpec.makeMeasureSpec(actionPaddedHeight, MeasureSpec.EXACTLY);
measureChild(mActionRow, childWidthMeasureSpec, actionHeightSpec);
// Total height should include action row and our padding
height += actionHeight + getPaddingTop() + getPaddingBottom();
setMeasuredDimension(width, height);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
View v = mCurrentView.getView();
final int left = getPaddingLeft();
final int top = getPaddingTop();
v.layout(left, top, left + v.getMeasuredWidth(), top + v.getMeasuredHeight());
if (mActionRow.getVisibility() != View.GONE) {
mActionRow.layout(left,
top + v.getMeasuredHeight(),
left + mActionRow.getMeasuredWidth(),
top + v.getMeasuredHeight() + mActionRow.getMeasuredHeight());
}
}
@Override
public void onChanged(@Nullable Slice slice) {
setSlice(slice);
}
/**
* Populates this view to the provided {@link Slice}.
*
* This will not update automatically if the slice content changes, for live
* content see {@link SliceLiveData}.
*/
public void setSlice(@Nullable Slice slice) {
if (slice != null) {
if (mCurrentSlice == null || !mCurrentSlice.getUri().equals(slice.getUri())) {
mCurrentView.resetView();
}
} else {
// No slice, no actions
mActions = null;
}
mActions = SliceMetadata.getSliceActions(slice);
mCurrentSlice = slice;
reinflate();
}
/**
* @return the slice being used to populate this view.
*/
@Nullable
public Slice getSlice() {
return mCurrentSlice;
}
/**
* Returns the slice actions presented in this view.
* <p>
* Note that these may be different from {@link SliceMetadata#getSliceActions()} if the actions
* set on the view have been adjusted using {@link #setSliceActions(List)}.
*/
@Nullable
public List<SliceItem> getSliceActions() {
return mActions;
}
/**
* Sets the slice actions to display for the slice contained in this view. Normally SliceView
* will automatically show actions, however, it is possible to reorder or omit actions on the
* view using this method. This is generally discouraged.
* <p>
* It is required that the slice be set on this view before actions can be set, otherwise
* this will throw {@link IllegalStateException}. If any of the actions supplied are not
* available for the slice set on this view (i.e. the action is not returned by
* {@link SliceMetadata#getSliceActions()} this will throw {@link IllegalArgumentException}.
*/
public void setSliceActions(@Nullable List<SliceItem> newActions) {
// Check that these actions are part of available set
if (mCurrentSlice == null) {
throw new IllegalStateException("Trying to set actions on a view without a slice");
}
List<SliceItem> availableActions = SliceMetadata.getSliceActions(mCurrentSlice);
if (availableActions != null && newActions != null) {
for (int i = 0; i < newActions.size(); i++) {
if (!availableActions.contains(newActions.get(i))) {
throw new IllegalArgumentException(
"Trying to set an action that isn't available: " + newActions.get(i));
}
}
}
mActions = newActions;
updateActions();
}
/**
* Set the mode this view should present in.
*/
public void setMode(@SliceMode int mode) {
setMode(mode, false /* animate */);
}
/**
* Set whether this view should allow scrollable content when presenting in {@link #MODE_LARGE}.
*/
public void setScrollable(boolean isScrollable) {
mIsScrollable = isScrollable;
reinflate();
}
/**
* Sets the listener to notify when an interaction events occur on the view.
* @see EventInfo
*/
public void setOnSliceActionListener(@Nullable OnSliceActionListener observer) {
mSliceObserver = observer;
mCurrentView.setSliceActionListener(mSliceObserver);
}
/**
* Contents of a slice such as icons, text, and controls (e.g. toggle) can be tinted. Normally
* a color for tinting will be provided by the slice. Using this method will override
* this color information and instead tint elements with the provided color.
*
* @param tintColor the color to use for tinting contents of this view.
*/
public void setTint(int tintColor) {
mThemeTintColor = tintColor;
mCurrentView.setTint(tintColor);
}
/**
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
public void setMode(@SliceMode int mode, boolean animate) {
if (animate) {
Log.e(TAG, "Animation not supported yet");
}
if (mMode == mode) {
return;
}
mMode = mode;
reinflate();
}
/**
* @return the mode this view is presenting in.
*/
public @SliceMode int getMode() {
return mMode;
}
/**
* @hide
*
* Whether this view should show a row of actions with it.
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
public void setShowActionRow(boolean show) {
mShowActions = show;
updateActions();
}
/**
* @return whether this view is showing a row of actions.
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
public boolean isShowingActionRow() {
return mShowActions;
}
private void reinflate() {
if (mCurrentSlice == null) {
mCurrentView.resetView();
return;
}
mListContent = new ListContent(getContext(), mCurrentSlice);
if (!mListContent.isValid()) {
mCurrentView.resetView();
return;
}
// TODO: Smarter mapping here from one state to the next.
int mode = getMode();
boolean isCurrentViewShortcut = mCurrentView instanceof ShortcutView;
if (mode == MODE_SHORTCUT && !isCurrentViewShortcut) {
removeAllViews();
mCurrentView = new ShortcutView(getContext());
addView(mCurrentView, getChildLp(mCurrentView));
} else if (mode != MODE_SHORTCUT && isCurrentViewShortcut) {
removeAllViews();
mCurrentView = new LargeTemplateView(getContext());
addView(mCurrentView, getChildLp(mCurrentView));
}
mCurrentView.setMode(mode);
mCurrentView.setSliceActionListener(mSliceObserver);
if (mCurrentView instanceof LargeTemplateView) {
((LargeTemplateView) mCurrentView).setScrollable(mIsScrollable);
}
mCurrentView.setStyle(mAttrs, mDefStyleAttr, mDefStyleRes);
mCurrentView.setTint(getTintColor());
// Check if the slice content is expired and show when it was last updated
SliceMetadata sliceMetadata = SliceMetadata.from(getContext(), mCurrentSlice);
long lastUpdated = sliceMetadata.getLastUpdatedTime();
long expiry = sliceMetadata.getExpiry();
long now = System.currentTimeMillis();
mCurrentView.setLastUpdated(lastUpdated);
boolean expired = expiry != 0 && expiry != SliceHints.INFINITY && now > expiry;
mCurrentView.setShowLastUpdated(mShowLastUpdated && expired);
// Set the slice
mCurrentView.setSlice(mCurrentSlice);
updateActions();
}
private void updateActions() {
if (mActions == null || mActions.isEmpty()) {
// No actions, hide the row, clear out the view
mActionRow.setVisibility(View.GONE);
mCurrentView.setSliceActions(null);
return;
}
// TODO: take priority attached to actions into account
if (mShowActions && mMode != MODE_SHORTCUT && mActions.size() >= 2) {
// Show in action row if available
mActionRow.setActions(mActions, getTintColor());
mActionRow.setVisibility(View.VISIBLE);
// Hide them on the template
mCurrentView.setSliceActions(null);
} else if (mActions.size() > 0) {
// Otherwise set them on the template
mCurrentView.setSliceActions(mActions);
mActionRow.setVisibility(View.GONE);
}
}
private int getTintColor() {
if (mThemeTintColor != -1) {
// Theme has specified a color, use that
return mThemeTintColor;
} else {
final SliceItem colorItem = SliceQuery.findSubtype(
mCurrentSlice, FORMAT_INT, SUBTYPE_COLOR);
return colorItem != null
? colorItem.getInt()
: SliceViewUtil.getColorAccent(getContext());
}
}
private LayoutParams getChildLp(View child) {
if (child instanceof ShortcutView) {
return new LayoutParams(mShortcutSize, mShortcutSize);
} else {
return new LayoutParams(LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT);
}
}
/**
* @return String representation of the provided mode.
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
public static String modeToString(@SliceMode int mode) {
switch(mode) {
case MODE_SHORTCUT:
return "MODE SHORTCUT";
case MODE_SMALL:
return "MODE SMALL";
case MODE_LARGE:
return "MODE LARGE";
default:
return "unknown mode: " + mode;
}
}
Runnable mLongpressCheck = new Runnable() {
@Override
public void run() {
if (mPressing && mLongClickListener != null) {
mInLongpress = true;
mLongClickListener.onLongClick(SliceView.this);
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
}
}
};
}