1 /* 2 * Copyright 2017 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.slice.widget; 18 19 import static android.app.slice.Slice.EXTRA_RANGE_VALUE; 20 import static android.app.slice.Slice.HINT_NO_TINT; 21 import static android.app.slice.Slice.HINT_PARTIAL; 22 import static android.app.slice.Slice.HINT_SHORTCUT; 23 import static android.app.slice.Slice.SUBTYPE_MAX; 24 import static android.app.slice.Slice.SUBTYPE_VALUE; 25 import static android.app.slice.SliceItem.FORMAT_ACTION; 26 import static android.app.slice.SliceItem.FORMAT_IMAGE; 27 import static android.app.slice.SliceItem.FORMAT_INT; 28 import static android.app.slice.SliceItem.FORMAT_LONG; 29 import static android.app.slice.SliceItem.FORMAT_SLICE; 30 31 import static androidx.slice.core.SliceHints.ICON_IMAGE; 32 import static androidx.slice.core.SliceHints.SMALL_IMAGE; 33 import static androidx.slice.core.SliceHints.SUBTYPE_MIN; 34 import static androidx.slice.widget.EventInfo.ACTION_TYPE_BUTTON; 35 import static androidx.slice.widget.EventInfo.ACTION_TYPE_TOGGLE; 36 import static androidx.slice.widget.EventInfo.ROW_TYPE_LIST; 37 import static androidx.slice.widget.EventInfo.ROW_TYPE_TOGGLE; 38 import static androidx.slice.widget.SliceView.MODE_SMALL; 39 40 import android.app.PendingIntent.CanceledException; 41 import android.content.Context; 42 import android.content.Intent; 43 import android.graphics.Typeface; 44 import android.graphics.drawable.Drawable; 45 import android.text.SpannableString; 46 import android.text.TextUtils; 47 import android.text.style.StyleSpan; 48 import android.util.ArrayMap; 49 import android.util.Log; 50 import android.util.TypedValue; 51 import android.view.LayoutInflater; 52 import android.view.View; 53 import android.view.ViewGroup; 54 import android.widget.Button; 55 import android.widget.ImageView; 56 import android.widget.LinearLayout; 57 import android.widget.ProgressBar; 58 import android.widget.SeekBar; 59 import android.widget.TextView; 60 61 import androidx.annotation.ColorInt; 62 import androidx.annotation.RestrictTo; 63 import androidx.core.graphics.drawable.DrawableCompat; 64 import androidx.core.graphics.drawable.IconCompat; 65 import androidx.slice.SliceItem; 66 import androidx.slice.core.SliceActionImpl; 67 import androidx.slice.core.SliceQuery; 68 import androidx.slice.view.R; 69 70 import java.util.List; 71 72 /** 73 * Row item is in small template format and can be used to construct list items for use 74 * with {@link LargeTemplateView}. 75 * 76 * @hide 77 */ 78 @RestrictTo(RestrictTo.Scope.LIBRARY) 79 public class RowView extends SliceChildView implements View.OnClickListener { 80 81 private static final String TAG = "RowView"; 82 83 // The number of items that fit on the right hand side of a small slice 84 private static final int MAX_END_ITEMS = 3; 85 86 private LinearLayout mRootView; 87 private LinearLayout mStartContainer; 88 private LinearLayout mContent; 89 private TextView mPrimaryText; 90 private TextView mSecondaryText; 91 private TextView mLastUpdatedText; 92 private View mDivider; 93 private ArrayMap<SliceActionImpl, SliceActionView> mToggles = new ArrayMap<>(); 94 private LinearLayout mEndContainer; 95 private ProgressBar mRangeBar; 96 private View mSeeMoreView; 97 98 private int mRowIndex; 99 private RowContent mRowContent; 100 private SliceActionImpl mRowAction; 101 private boolean mIsHeader; 102 private List<SliceItem> mHeaderActions; 103 private boolean mIsSingleItem; 104 105 private int mImageSize; 106 private int mIconSize; 107 private int mRangeHeight; 108 109 public RowView(Context context) { 110 super(context); 111 mIconSize = getContext().getResources().getDimensionPixelSize(R.dimen.abc_slice_icon_size); 112 mImageSize = getContext().getResources().getDimensionPixelSize( 113 R.dimen.abc_slice_small_image_size); 114 mRootView = (LinearLayout) LayoutInflater.from(context).inflate( 115 R.layout.abc_slice_small_template, this, false); 116 addView(mRootView); 117 118 mStartContainer = (LinearLayout) findViewById(R.id.icon_frame); 119 mContent = (LinearLayout) findViewById(android.R.id.content); 120 mPrimaryText = (TextView) findViewById(android.R.id.title); 121 mSecondaryText = (TextView) findViewById(android.R.id.summary); 122 mLastUpdatedText = (TextView) findViewById(R.id.last_updated); 123 mDivider = findViewById(R.id.divider); 124 mEndContainer = (LinearLayout) findViewById(android.R.id.widget_frame); 125 126 mRangeHeight = context.getResources().getDimensionPixelSize( 127 R.dimen.abc_slice_row_range_height); 128 } 129 130 /** 131 * Set whether this is the only row in the view, in which case our height is different. 132 */ 133 public void setSingleItem(boolean isSingleItem) { 134 mIsSingleItem = isSingleItem; 135 } 136 137 @Override 138 public int getSmallHeight() { 139 // RowView is in small format when it is the header of a list and displays at max height. 140 return mRowContent != null && mRowContent.isValid() ? mRowContent.getSmallHeight() : 0; 141 } 142 143 @Override 144 public int getActualHeight() { 145 if (mIsSingleItem) { 146 return getSmallHeight(); 147 } 148 return mRowContent != null && mRowContent.isValid() ? mRowContent.getActualHeight() : 0; 149 } 150 /** 151 * @return height row content (i.e. title, subtitle) without the height of the range element. 152 */ 153 private int getRowContentHeight() { 154 int rowHeight = (getMode() == MODE_SMALL || mIsSingleItem) 155 ? getSmallHeight() 156 : getActualHeight(); 157 if (mRangeBar != null) { 158 rowHeight -= mRangeHeight; 159 } 160 return rowHeight; 161 } 162 163 @Override 164 public void setTint(@ColorInt int tintColor) { 165 super.setTint(tintColor); 166 if (mRowContent != null) { 167 // TODO -- can be smarter about this 168 populateViews(); 169 } 170 } 171 172 @Override 173 public void setSliceActions(List<SliceItem> actions) { 174 mHeaderActions = actions; 175 if (mRowContent != null) { 176 populateViews(); 177 } 178 } 179 180 @Override 181 public void setShowLastUpdated(boolean showLastUpdated) { 182 super.setShowLastUpdated(showLastUpdated); 183 if (mRowContent != null) { 184 populateViews(); 185 } 186 } 187 188 @Override 189 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 190 int totalHeight = getMode() == MODE_SMALL ? getSmallHeight() : getActualHeight(); 191 int rowHeight = getRowContentHeight(); 192 if (rowHeight != 0) { 193 // Might be gone if we have range / progress but nothing else 194 mRootView.setVisibility(View.VISIBLE); 195 heightMeasureSpec = MeasureSpec.makeMeasureSpec(rowHeight, MeasureSpec.EXACTLY); 196 measureChild(mRootView, widthMeasureSpec, heightMeasureSpec); 197 } else { 198 mRootView.setVisibility(View.GONE); 199 } 200 if (mRangeBar != null) { 201 int rangeMeasureSpec = MeasureSpec.makeMeasureSpec(mRangeHeight, MeasureSpec.EXACTLY); 202 measureChild(mRangeBar, widthMeasureSpec, rangeMeasureSpec); 203 } 204 205 int totalHeightSpec = MeasureSpec.makeMeasureSpec(totalHeight, MeasureSpec.EXACTLY); 206 super.onMeasure(widthMeasureSpec, totalHeightSpec); 207 } 208 209 @Override 210 protected void onLayout(boolean changed, int l, int t, int r, int b) { 211 mRootView.layout(0, 0, mRootView.getMeasuredWidth(), getRowContentHeight()); 212 if (mRangeBar != null) { 213 mRangeBar.layout(0, getRowContentHeight(), mRangeBar.getMeasuredWidth(), 214 getRowContentHeight() + mRangeHeight); 215 } 216 } 217 218 /** 219 * This is called when RowView is being used as a component in a large template. 220 */ 221 @Override 222 public void setSliceItem(SliceItem slice, boolean isHeader, int index, 223 int rowCount, SliceView.OnSliceActionListener observer) { 224 setSliceActionListener(observer); 225 mRowIndex = index; 226 mIsHeader = ListContent.isValidHeader(slice); 227 mHeaderActions = null; 228 mRowContent = new RowContent(getContext(), slice, mIsHeader); 229 populateViews(); 230 } 231 232 private void populateViews() { 233 resetView(); 234 if (mRowContent.isDefaultSeeMore()) { 235 showSeeMore(); 236 return; 237 } 238 CharSequence contentDescr = mRowContent.getContentDescription(); 239 if (contentDescr != null) { 240 mContent.setContentDescription(contentDescr); 241 } 242 final SliceItem startItem = mRowContent.getStartItem(); 243 boolean showStart = startItem != null && mRowIndex > 0; 244 if (showStart) { 245 showStart = addItem(startItem, mTintColor, true /* isStart */); 246 } 247 mStartContainer.setVisibility(showStart ? View.VISIBLE : View.GONE); 248 249 final SliceItem titleItem = mRowContent.getTitleItem(); 250 if (titleItem != null) { 251 mPrimaryText.setText(titleItem.getText()); 252 } 253 mPrimaryText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mIsHeader 254 ? mHeaderTitleSize 255 : mTitleSize); 256 mPrimaryText.setTextColor(mTitleColor); 257 mPrimaryText.setVisibility(titleItem != null ? View.VISIBLE : View.GONE); 258 259 final SliceItem subtitleItem = getMode() == MODE_SMALL 260 ? mRowContent.getSummaryItem() 261 : mRowContent.getSubtitleItem(); 262 addSubtitle(subtitleItem); 263 264 SliceItem primaryAction = mRowContent.getPrimaryAction(); 265 if (primaryAction != null && primaryAction != startItem) { 266 mRowAction = new SliceActionImpl(primaryAction); 267 if (mRowAction.isToggle()) { 268 // If primary action is a toggle, add it and we're done 269 addAction(mRowAction, mTintColor, mEndContainer, false /* isStart */); 270 // TODO: if start item is tappable, touch feedback should exclude it 271 setViewClickable(mRootView, true); 272 return; 273 } 274 } 275 276 final SliceItem range = mRowContent.getRange(); 277 if (range != null) { 278 if (mRowAction != null) { 279 setViewClickable(mRootView, true); 280 } 281 addRange(range); 282 return; 283 } 284 285 // If we're here we can can show end items; check for top level actions first 286 List<SliceItem> endItems = mRowContent.getEndItems(); 287 if (mHeaderActions != null && mHeaderActions.size() > 0) { 288 // Use these if we have them instead 289 endItems = mHeaderActions; 290 } 291 // If we're here we might be able to show end items 292 int itemCount = 0; 293 boolean firstItemIsADefaultToggle = false; 294 boolean hasEndItemAction = false; 295 for (int i = 0; i < endItems.size(); i++) { 296 final SliceItem endItem = endItems.get(i); 297 if (itemCount < MAX_END_ITEMS) { 298 if (addItem(endItem, mTintColor, false /* isStart */)) { 299 if (SliceQuery.find(endItem, FORMAT_ACTION) != null) { 300 hasEndItemAction = true; 301 } 302 itemCount++; 303 if (itemCount == 1) { 304 firstItemIsADefaultToggle = !mToggles.isEmpty() 305 && SliceQuery.find(endItem.getSlice(), FORMAT_IMAGE) == null; 306 } 307 } 308 } 309 } 310 311 // If there is a row action and the first end item is a default toggle, show the divider. 312 mDivider.setVisibility(mRowAction != null && firstItemIsADefaultToggle 313 ? View.VISIBLE : View.GONE); 314 boolean hasStartAction = startItem != null 315 && SliceQuery.find(startItem, FORMAT_ACTION) != null; 316 317 if (mRowAction != null) { 318 // If there are outside actions make only the content bit clickable 319 // TODO: if start item is an image touch feedback should include it 320 setViewClickable((hasEndItemAction || hasStartAction) ? mContent : mRootView, true); 321 } else if (hasEndItemAction != hasStartAction && (itemCount == 1 || hasStartAction)) { 322 // Single action; make whole row clickable for it 323 if (!mToggles.isEmpty()) { 324 mRowAction = mToggles.keySet().iterator().next(); 325 } else { 326 mRowAction = new SliceActionImpl(hasEndItemAction ? endItems.get(0) : startItem); 327 } 328 setViewClickable(mRootView, true); 329 } 330 } 331 332 private void addSubtitle(final SliceItem subtitleItem) { 333 CharSequence subtitleTimeString = null; 334 if (mShowLastUpdated && mLastUpdated != -1) { 335 subtitleTimeString = getResources().getString(R.string.abc_slice_updated, 336 SliceViewUtil.getRelativeTimeString(mLastUpdated)); 337 } 338 CharSequence subtitle = subtitleItem != null ? subtitleItem.getText() : null; 339 boolean subtitleExists = !TextUtils.isEmpty(subtitle) 340 || (subtitleItem != null && subtitleItem.hasHint(HINT_PARTIAL)); 341 if (subtitleExists) { 342 mSecondaryText.setText(subtitle); 343 mSecondaryText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mIsHeader 344 ? mHeaderSubtitleSize 345 : mSubtitleSize); 346 mSecondaryText.setTextColor(mSubtitleColor); 347 int verticalPadding = mIsHeader ? mVerticalHeaderTextPadding : mVerticalTextPadding; 348 mSecondaryText.setPadding(0, verticalPadding, 0, 0); 349 } 350 if (subtitleTimeString != null) { 351 if (!TextUtils.isEmpty(subtitle)) { 352 subtitleTimeString = " \u00B7 " + subtitleTimeString; 353 } 354 SpannableString sp = new SpannableString(subtitleTimeString); 355 sp.setSpan(new StyleSpan(Typeface.ITALIC), 0, subtitleTimeString.length(), 0); 356 mLastUpdatedText.setText(sp); 357 mLastUpdatedText.setTextSize(TypedValue.COMPLEX_UNIT_PX, 358 mIsHeader ? mHeaderSubtitleSize : mSubtitleSize); 359 mLastUpdatedText.setTextColor(mSubtitleColor); 360 } 361 mLastUpdatedText.setVisibility(TextUtils.isEmpty(subtitleTimeString) ? GONE : VISIBLE); 362 mSecondaryText.setVisibility(subtitleExists ? VISIBLE : GONE); 363 } 364 365 private void addRange(final SliceItem range) { 366 final boolean isSeekBar = FORMAT_ACTION.equals(range.getFormat()); 367 final ProgressBar progressBar = isSeekBar 368 ? new SeekBar(getContext()) 369 : new ProgressBar(getContext(), null, android.R.attr.progressBarStyleHorizontal); 370 if (mTintColor != -1) { 371 Drawable drawable = DrawableCompat.wrap(progressBar.getProgressDrawable()); 372 DrawableCompat.setTint(drawable, mTintColor); 373 progressBar.setProgressDrawable(drawable); 374 } 375 // TODO: Need to handle custom accessibility for min 376 SliceItem min = SliceQuery.findSubtype(range, FORMAT_INT, SUBTYPE_MIN); 377 int minValue = 0; 378 if (min != null) { 379 minValue = min.getInt(); 380 } 381 SliceItem max = SliceQuery.findSubtype(range, FORMAT_INT, SUBTYPE_MAX); 382 if (max != null) { 383 progressBar.setMax(max.getInt() - minValue); 384 } 385 SliceItem progress = SliceQuery.findSubtype(range, FORMAT_INT, SUBTYPE_VALUE); 386 if (progress != null) { 387 progressBar.setProgress(progress.getInt() - minValue); 388 } 389 progressBar.setVisibility(View.VISIBLE); 390 addView(progressBar); 391 mRangeBar = progressBar; 392 if (isSeekBar) { 393 SliceItem thumb = mRowContent.getInputRangeThumb(); 394 SeekBar seekBar = (SeekBar) mRangeBar; 395 if (thumb != null) { 396 seekBar.setThumb(thumb.getIcon().loadDrawable(getContext())); 397 } 398 if (mTintColor != -1) { 399 Drawable drawable = DrawableCompat.wrap(seekBar.getThumb()); 400 DrawableCompat.setTint(drawable, mTintColor); 401 seekBar.setThumb(drawable); 402 } 403 final int finalMinValue = minValue; 404 seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { 405 @Override 406 public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { 407 progress += finalMinValue; 408 try { 409 // TODO: sending this PendingIntent should be rate limited. 410 range.fireAction(getContext(), 411 new Intent().putExtra(EXTRA_RANGE_VALUE, progress)); 412 } catch (CanceledException e) { } 413 } 414 415 @Override 416 public void onStartTrackingTouch(SeekBar seekBar) { } 417 418 @Override 419 public void onStopTrackingTouch(SeekBar seekBar) { } 420 }); 421 } 422 } 423 424 /** 425 * Add an action view to the container. 426 */ 427 private void addAction(final SliceActionImpl actionContent, int color, ViewGroup container, 428 boolean isStart) { 429 SliceActionView sav = new SliceActionView(getContext()); 430 container.addView(sav); 431 432 boolean isToggle = actionContent.isToggle(); 433 int actionType = isToggle ? ACTION_TYPE_TOGGLE : ACTION_TYPE_BUTTON; 434 int rowType = isToggle ? ROW_TYPE_TOGGLE : ROW_TYPE_LIST; 435 EventInfo info = new EventInfo(getMode(), actionType, rowType, mRowIndex); 436 if (isStart) { 437 info.setPosition(EventInfo.POSITION_START, 0, 1); 438 } 439 sav.setAction(actionContent, info, mObserver, color); 440 if (isToggle) { 441 mToggles.put(actionContent, sav); 442 } 443 } 444 445 /** 446 * Adds simple items to a container. Simple items include actions with icons, images, or 447 * timestamps. 448 */ 449 private boolean addItem(SliceItem sliceItem, int color, boolean isStart) { 450 IconCompat icon = null; 451 int imageMode = 0; 452 SliceItem timeStamp = null; 453 ViewGroup container = isStart ? mStartContainer : mEndContainer; 454 if (FORMAT_SLICE.equals(sliceItem.getFormat()) 455 || FORMAT_ACTION.equals(sliceItem.getFormat())) { 456 if (sliceItem.hasHint(HINT_SHORTCUT)) { 457 addAction(new SliceActionImpl(sliceItem), color, container, isStart); 458 return true; 459 } else { 460 sliceItem = sliceItem.getSlice().getItems().get(0); 461 } 462 } 463 464 if (FORMAT_IMAGE.equals(sliceItem.getFormat())) { 465 icon = sliceItem.getIcon(); 466 imageMode = sliceItem.hasHint(HINT_NO_TINT) ? SMALL_IMAGE : ICON_IMAGE; 467 } else if (FORMAT_LONG.equals(sliceItem.getFormat())) { 468 timeStamp = sliceItem; 469 } 470 View addedView = null; 471 if (icon != null) { 472 boolean isIcon = imageMode == ICON_IMAGE; 473 ImageView iv = new ImageView(getContext()); 474 iv.setImageDrawable(icon.loadDrawable(getContext())); 475 if (isIcon && color != -1) { 476 iv.setColorFilter(color); 477 } 478 container.addView(iv); 479 LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) iv.getLayoutParams(); 480 lp.width = mImageSize; 481 lp.height = mImageSize; 482 iv.setLayoutParams(lp); 483 int p = isIcon ? mIconSize / 2 : 0; 484 iv.setPadding(p, p, p, p); 485 addedView = iv; 486 } else if (timeStamp != null) { 487 TextView tv = new TextView(getContext()); 488 tv.setText(SliceViewUtil.getRelativeTimeString(sliceItem.getTimestamp())); 489 tv.setTextSize(TypedValue.COMPLEX_UNIT_PX, mSubtitleSize); 490 tv.setTextColor(mSubtitleColor); 491 container.addView(tv); 492 addedView = tv; 493 } 494 return addedView != null; 495 } 496 497 private void showSeeMore() { 498 Button b = (Button) LayoutInflater.from(getContext()).inflate( 499 R.layout.abc_slice_row_show_more, this, false); 500 b.setOnClickListener(new View.OnClickListener() { 501 @Override 502 public void onClick(View v) { 503 try { 504 if (mObserver != null) { 505 EventInfo info = new EventInfo(getMode(), EventInfo.ACTION_TYPE_SEE_MORE, 506 EventInfo.ROW_TYPE_LIST, mRowIndex); 507 mObserver.onSliceAction(info, mRowContent.getSlice()); 508 } 509 mRowContent.getSlice().fireAction(null, null); 510 } catch (CanceledException e) { 511 Log.e(TAG, "PendingIntent for slice cannot be sent", e); 512 } 513 } 514 }); 515 if (mTintColor != -1) { 516 b.setTextColor(mTintColor); 517 } 518 mSeeMoreView = b; 519 mRootView.addView(mSeeMoreView); 520 } 521 522 @Override 523 public void onClick(View view) { 524 if (mRowAction != null && mRowAction.getActionItem() != null) { 525 // Check if it's a row click for a toggle, in this case need to update the UI 526 if (mRowAction.isToggle() && !(view instanceof SliceActionView)) { 527 SliceActionView sav = mToggles.get(mRowAction); 528 if (sav != null) { 529 sav.toggle(); 530 } 531 } else { 532 try { 533 mRowAction.getActionItem().fireAction(null, null); 534 if (mObserver != null) { 535 EventInfo info = new EventInfo(getMode(), EventInfo.ACTION_TYPE_CONTENT, 536 EventInfo.ROW_TYPE_LIST, mRowIndex); 537 mObserver.onSliceAction(info, mRowAction.getSliceItem()); 538 } 539 } catch (CanceledException e) { 540 Log.e(TAG, "PendingIntent for slice cannot be sent", e); 541 } 542 } 543 } 544 } 545 546 private void setViewClickable(View layout, boolean isClickable) { 547 layout.setOnClickListener(isClickable ? this : null); 548 layout.setBackground(isClickable 549 ? SliceViewUtil.getDrawable(getContext(), android.R.attr.selectableItemBackground) 550 : null); 551 layout.setClickable(isClickable); 552 } 553 554 @Override 555 public void resetView() { 556 mRootView.setVisibility(VISIBLE); 557 setViewClickable(mRootView, false); 558 setViewClickable(mContent, false); 559 mStartContainer.removeAllViews(); 560 mEndContainer.removeAllViews(); 561 mPrimaryText.setText(null); 562 mSecondaryText.setText(null); 563 mLastUpdatedText.setText(null); 564 mLastUpdatedText.setVisibility(GONE); 565 mToggles.clear(); 566 mRowAction = null; 567 mDivider.setVisibility(GONE); 568 if (mRangeBar != null) { 569 removeView(mRangeBar); 570 } 571 if (mSeeMoreView != null) { 572 mRootView.removeView(mSeeMoreView); 573 } 574 } 575 } 576