1 /* 2 * Copyright (C) 2015 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 com.android.messaging.ui; 18 19 import android.content.Context; 20 import android.content.res.TypedArray; 21 import android.graphics.Canvas; 22 import android.graphics.Color; 23 import android.graphics.Path; 24 import android.graphics.RectF; 25 import android.graphics.drawable.ColorDrawable; 26 import android.graphics.drawable.Drawable; 27 import androidx.annotation.Nullable; 28 import android.support.rastermill.FrameSequenceDrawable; 29 import android.text.TextUtils; 30 import android.util.AttributeSet; 31 import android.widget.ImageView; 32 33 import com.android.messaging.R; 34 import com.android.messaging.datamodel.binding.Binding; 35 import com.android.messaging.datamodel.binding.BindingBase; 36 import com.android.messaging.datamodel.media.BindableMediaRequest; 37 import com.android.messaging.datamodel.media.GifImageResource; 38 import com.android.messaging.datamodel.media.ImageRequest; 39 import com.android.messaging.datamodel.media.ImageRequestDescriptor; 40 import com.android.messaging.datamodel.media.ImageResource; 41 import com.android.messaging.datamodel.media.MediaRequest; 42 import com.android.messaging.datamodel.media.MediaResourceManager; 43 import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; 44 import com.android.messaging.util.Assert; 45 import com.android.messaging.util.LogUtil; 46 import com.android.messaging.util.ThreadUtil; 47 import com.android.messaging.util.UiUtils; 48 import com.google.common.annotations.VisibleForTesting; 49 50 import java.util.HashSet; 51 52 /** 53 * An ImageView used to asynchronously request an image from MediaResourceManager and render it. 54 */ 55 public class AsyncImageView extends ImageView implements MediaResourceLoadListener<ImageResource> { 56 private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; 57 // 100ms delay before disposing the image in case the AsyncImageView is re-added to the UI 58 private static final int DISPOSE_IMAGE_DELAY = 100; 59 60 // AsyncImageView has a 1-1 binding relationship with an ImageRequest instance that requests 61 // the image from the MediaResourceManager. Since the request is done asynchronously, we 62 // want to make sure the image view is always bound to the latest image request that it 63 // issues, so that when the image is loaded, the ImageRequest (which extends BindableData) 64 // will be able to figure out whether the binding is still valid and whether the loaded image 65 // should be delivered to the AsyncImageView via onMediaResourceLoaded() callback. 66 @VisibleForTesting 67 public final Binding<BindableMediaRequest<ImageResource>> mImageRequestBinding; 68 69 /** True if we want the image to fade in when it loads */ 70 private boolean mFadeIn; 71 72 /** True if we want the image to reveal (scale) when it loads. When set to true, this 73 * will take precedence over {@link #mFadeIn} */ 74 private final boolean mReveal; 75 76 // The corner radius for drawing rounded corners around bitmap. The default value is zero 77 // (no rounded corners) 78 private final int mCornerRadius; 79 private final Path mRoundedCornerClipPath; 80 private int mClipPathWidth; 81 private int mClipPathHeight; 82 83 // A placeholder drawable that takes the spot of the image when it's loading. The default 84 // setting is null (no placeholder). 85 private final Drawable mPlaceholderDrawable; 86 protected ImageResource mImageResource; 87 private final Runnable mDisposeRunnable = new Runnable() { 88 @Override 89 public void run() { 90 if (mImageRequestBinding.isBound()) { 91 mDetachedRequestDescriptor = (ImageRequestDescriptor) 92 mImageRequestBinding.getData().getDescriptor(); 93 } 94 unbindView(); 95 releaseImageResource(); 96 } 97 }; 98 99 private AsyncImageViewDelayLoader mDelayLoader; 100 private ImageRequestDescriptor mDetachedRequestDescriptor; 101 AsyncImageView(final Context context, final AttributeSet attrs)102 public AsyncImageView(final Context context, final AttributeSet attrs) { 103 super(context, attrs); 104 mImageRequestBinding = BindingBase.createBinding(this); 105 final TypedArray attr = context.obtainStyledAttributes(attrs, R.styleable.AsyncImageView, 106 0, 0); 107 mFadeIn = attr.getBoolean(R.styleable.AsyncImageView_fadeIn, true); 108 mReveal = attr.getBoolean(R.styleable.AsyncImageView_reveal, false); 109 mPlaceholderDrawable = attr.getDrawable(R.styleable.AsyncImageView_placeholderDrawable); 110 mCornerRadius = attr.getDimensionPixelSize(R.styleable.AsyncImageView_cornerRadius, 0); 111 mRoundedCornerClipPath = new Path(); 112 113 attr.recycle(); 114 } 115 116 /** 117 * The main entrypoint for AsyncImageView to load image resource given an ImageRequestDescriptor 118 * @param descriptor the request descriptor, or null if no image should be displayed 119 */ setImageResourceId(@ullable final ImageRequestDescriptor descriptor)120 public void setImageResourceId(@Nullable final ImageRequestDescriptor descriptor) { 121 final String requestKey = (descriptor == null) ? null : descriptor.getKey(); 122 if (mImageRequestBinding.isBound()) { 123 if (TextUtils.equals(mImageRequestBinding.getData().getKey(), requestKey)) { 124 // Don't re-request the bitmap if the new request is for the same resource. 125 return; 126 } 127 unbindView(); 128 } else { 129 mDetachedRequestDescriptor = null; 130 } 131 setImage(null); 132 resetTransientViewStates(); 133 if (!TextUtils.isEmpty(requestKey)) { 134 maybeSetupPlaceholderDrawable(descriptor); 135 final BindableMediaRequest<ImageResource> imageRequest = 136 descriptor.buildAsyncMediaRequest(getContext(), this); 137 requestImage(imageRequest); 138 } 139 } 140 141 /** 142 * Sets a delay loader that centrally manages image request delay loading logic. 143 */ setDelayLoader(final AsyncImageViewDelayLoader delayLoader)144 public void setDelayLoader(final AsyncImageViewDelayLoader delayLoader) { 145 Assert.isTrue(mDelayLoader == null); 146 mDelayLoader = delayLoader; 147 } 148 149 /** 150 * Called by the delay loader when we can resume image loading. 151 */ resumeLoading()152 public void resumeLoading() { 153 Assert.notNull(mDelayLoader); 154 Assert.isTrue(mImageRequestBinding.isBound()); 155 MediaResourceManager.get().requestMediaResourceAsync(mImageRequestBinding.getData()); 156 } 157 158 /** 159 * Setup the placeholder drawable if: 160 * 1. There's an image to be loaded AND 161 * 2. We are given a placeholder drawable AND 162 * 3. The descriptor provided us with source width and height. 163 */ maybeSetupPlaceholderDrawable(final ImageRequestDescriptor descriptor)164 private void maybeSetupPlaceholderDrawable(final ImageRequestDescriptor descriptor) { 165 if (!TextUtils.isEmpty(descriptor.getKey()) && mPlaceholderDrawable != null) { 166 if (descriptor.sourceWidth != ImageRequest.UNSPECIFIED_SIZE && 167 descriptor.sourceHeight != ImageRequest.UNSPECIFIED_SIZE) { 168 // Set a transparent inset drawable to the foreground so it will mimick the final 169 // size of the image, and use the background to show the actual placeholder 170 // drawable. 171 setImageDrawable(PlaceholderInsetDrawable.fromDrawable( 172 new ColorDrawable(Color.TRANSPARENT), 173 descriptor.sourceWidth, descriptor.sourceHeight)); 174 } 175 setBackground(mPlaceholderDrawable); 176 } 177 } 178 setImage(final ImageResource resource)179 protected void setImage(final ImageResource resource) { 180 setImage(resource, false /* isCached */); 181 } 182 setImage(final ImageResource resource, final boolean isCached)183 protected void setImage(final ImageResource resource, final boolean isCached) { 184 // Switch reference to the new ImageResource. Make sure we release the current 185 // resource and addRef() on the new resource so that the underlying bitmaps don't 186 // get leaked or get recycled by the bitmap cache. 187 releaseImageResource(); 188 // Ensure that any pending dispose runnables get removed. 189 ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable); 190 // The drawable may require work to get if its a static object so try to only make this call 191 // once. 192 final Drawable drawable = (resource != null) ? resource.getDrawable(getResources()) : null; 193 if (drawable != null) { 194 mImageResource = resource; 195 mImageResource.addRef(); 196 setImageDrawable(drawable); 197 if (drawable instanceof FrameSequenceDrawable) { 198 ((FrameSequenceDrawable) drawable).start(); 199 } 200 201 if (getVisibility() == VISIBLE) { 202 if (mReveal) { 203 setVisibility(INVISIBLE); 204 UiUtils.revealOrHideViewWithAnimation(this, VISIBLE, null); 205 } else if (mFadeIn && !isCached) { 206 // Hide initially to avoid flash. 207 setAlpha(0F); 208 animate().alpha(1F).start(); 209 } 210 } 211 212 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { 213 if (mImageResource instanceof GifImageResource) { 214 LogUtil.v(TAG, "setImage size unknown -- it's a GIF"); 215 } else { 216 LogUtil.v(TAG, "setImage size: " + mImageResource.getMediaSize() + 217 " width: " + mImageResource.getBitmap().getWidth() + 218 " heigh: " + mImageResource.getBitmap().getHeight()); 219 } 220 } 221 } 222 invalidate(); 223 } 224 requestImage(final BindableMediaRequest<ImageResource> request)225 private void requestImage(final BindableMediaRequest<ImageResource> request) { 226 mImageRequestBinding.bind(request); 227 if (mDelayLoader == null || !mDelayLoader.isDelayLoadingImage()) { 228 MediaResourceManager.get().requestMediaResourceAsync(request); 229 } else { 230 mDelayLoader.registerView(this); 231 } 232 } 233 234 @Override onMediaResourceLoaded(final MediaRequest<ImageResource> request, final ImageResource resource, final boolean isCached)235 public void onMediaResourceLoaded(final MediaRequest<ImageResource> request, 236 final ImageResource resource, final boolean isCached) { 237 if (mImageResource != resource) { 238 setImage(resource, isCached); 239 } 240 } 241 242 @Override onMediaResourceLoadError( final MediaRequest<ImageResource> request, final Exception exception)243 public void onMediaResourceLoadError( 244 final MediaRequest<ImageResource> request, final Exception exception) { 245 // Media load failed, unbind and reset bitmap to default. 246 unbindView(); 247 setImage(null); 248 } 249 releaseImageResource()250 private void releaseImageResource() { 251 final Drawable drawable = getDrawable(); 252 if (drawable instanceof FrameSequenceDrawable) { 253 ((FrameSequenceDrawable) drawable).stop(); 254 ((FrameSequenceDrawable) drawable).destroy(); 255 } 256 if (mImageResource != null) { 257 mImageResource.release(); 258 mImageResource = null; 259 } 260 setImageDrawable(null); 261 setBackground(null); 262 } 263 264 /** 265 * Resets transient view states (eg. alpha, animations) before rebinding/reusing the view. 266 */ resetTransientViewStates()267 private void resetTransientViewStates() { 268 clearAnimation(); 269 setAlpha(1F); 270 } 271 272 @Override onAttachedToWindow()273 protected void onAttachedToWindow() { 274 super.onAttachedToWindow(); 275 // If it was recently removed, then cancel disposing, we're still using it. 276 ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable); 277 278 // When the image view gets detached and immediately re-attached, any fade-in animation 279 // will be terminated, leaving the view in a semi-transparent state. Make sure we restore 280 // alpha when the view is re-attached. 281 if (mFadeIn) { 282 setAlpha(1F); 283 } 284 285 // Check whether we are in a simple reuse scenario: detached from window, and reattached 286 // later without rebinding. This may be done by containers such as the RecyclerView to 287 // reuse the views. In this case, we would like to rebind the original image request. 288 if (!mImageRequestBinding.isBound() && mDetachedRequestDescriptor != null) { 289 setImageResourceId(mDetachedRequestDescriptor); 290 } 291 mDetachedRequestDescriptor = null; 292 } 293 294 @Override onDetachedFromWindow()295 protected void onDetachedFromWindow() { 296 super.onDetachedFromWindow(); 297 // Dispose the bitmap, but if an AysncImageView is removed from the window, then quickly 298 // re-added, we shouldn't dispose, so wait a short time before disposing 299 ThreadUtil.getMainThreadHandler().postDelayed(mDisposeRunnable, DISPOSE_IMAGE_DELAY); 300 } 301 302 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)303 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 304 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 305 306 // The base implementation does not honor the minimum sizes. We try to to honor it here. 307 308 final int measuredWidth = getMeasuredWidth(); 309 final int measuredHeight = getMeasuredHeight(); 310 if (measuredWidth >= getMinimumWidth() || measuredHeight >= getMinimumHeight()) { 311 // We are ok if either of the minimum sizes is honored. Note that satisfying both the 312 // sizes may not be possible, depending on the aspect ratio of the image and whether 313 // a maximum size has been specified. This implementation only tries to handle the case 314 // where both the minimum sizes are not being satisfied. 315 return; 316 } 317 318 if (!getAdjustViewBounds()) { 319 // The base implementation is reasonable in this case. If the view bounds cannot be 320 // changed, it is not possible to satisfy the minimum sizes anyway. 321 return; 322 } 323 324 final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); 325 final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); 326 if (widthSpecMode == MeasureSpec.EXACTLY && heightSpecMode == MeasureSpec.EXACTLY) { 327 // The base implementation is reasonable in this case. 328 return; 329 } 330 331 int width = measuredWidth; 332 int height = measuredHeight; 333 // Get the minimum sizes that will honor other constraints as well. 334 final int minimumWidth = resolveSize( 335 getMinimumWidth(), getMaxWidth(), widthMeasureSpec); 336 final int minimumHeight = resolveSize( 337 getMinimumHeight(), getMaxHeight(), heightMeasureSpec); 338 final float aspectRatio = measuredWidth / (float) measuredHeight; 339 if (aspectRatio == 0) { 340 // If the image is (close to) infinitely high, there is not much we can do. 341 return; 342 } 343 344 if (width < minimumWidth) { 345 height = resolveSize((int) (minimumWidth / aspectRatio), 346 getMaxHeight(), heightMeasureSpec); 347 width = (int) (height * aspectRatio); 348 } 349 350 if (height < minimumHeight) { 351 width = resolveSize((int) (minimumHeight * aspectRatio), 352 getMaxWidth(), widthMeasureSpec); 353 height = (int) (width / aspectRatio); 354 } 355 356 setMeasuredDimension(width, height); 357 } 358 resolveSize(int desiredSize, int maxSize, int measureSpec)359 private static int resolveSize(int desiredSize, int maxSize, int measureSpec) { 360 final int specMode = MeasureSpec.getMode(measureSpec); 361 final int specSize = MeasureSpec.getSize(measureSpec); 362 switch(specMode) { 363 case MeasureSpec.UNSPECIFIED: 364 return Math.min(desiredSize, maxSize); 365 366 case MeasureSpec.AT_MOST: 367 return Math.min(Math.min(desiredSize, specSize), maxSize); 368 369 default: 370 Assert.fail("Unreachable"); 371 return specSize; 372 } 373 } 374 375 @Override onDraw(final Canvas canvas)376 protected void onDraw(final Canvas canvas) { 377 if (mCornerRadius > 0) { 378 final int currentWidth = this.getWidth(); 379 final int currentHeight = this.getHeight(); 380 if (mClipPathWidth != currentWidth || mClipPathHeight != currentHeight) { 381 final RectF rect = new RectF(0, 0, currentWidth, currentHeight); 382 mRoundedCornerClipPath.reset(); 383 mRoundedCornerClipPath.addRoundRect(rect, mCornerRadius, mCornerRadius, 384 Path.Direction.CW); 385 mClipPathWidth = currentWidth; 386 mClipPathHeight = currentHeight; 387 } 388 389 final int saveCount = canvas.getSaveCount(); 390 canvas.save(); 391 canvas.clipPath(mRoundedCornerClipPath); 392 super.onDraw(canvas); 393 canvas.restoreToCount(saveCount); 394 } else { 395 super.onDraw(canvas); 396 } 397 } 398 unbindView()399 private void unbindView() { 400 if (mImageRequestBinding.isBound()) { 401 mImageRequestBinding.unbind(); 402 if (mDelayLoader != null) { 403 mDelayLoader.unregisterView(this); 404 } 405 } 406 } 407 408 /** 409 * As a performance optimization, the consumer of the AsyncImageView may opt to delay loading 410 * the image when it's busy doing other things (such as when a list view is scrolling). In 411 * order to do this, the consumer can create a new AsyncImageViewDelayLoader instance to be 412 * shared among all relevant AsyncImageViews (through setDelayLoader() method), and call 413 * onStartDelayLoading() and onStopDelayLoading() to start and stop delay loading, respectively. 414 */ 415 public static class AsyncImageViewDelayLoader { 416 private boolean mShouldDelayLoad; 417 private final HashSet<AsyncImageView> mAttachedViews; 418 AsyncImageViewDelayLoader()419 public AsyncImageViewDelayLoader() { 420 mAttachedViews = new HashSet<AsyncImageView>(); 421 } 422 registerView(final AsyncImageView view)423 private void registerView(final AsyncImageView view) { 424 mAttachedViews.add(view); 425 } 426 unregisterView(final AsyncImageView view)427 private void unregisterView(final AsyncImageView view) { 428 mAttachedViews.remove(view); 429 } 430 isDelayLoadingImage()431 public boolean isDelayLoadingImage() { 432 return mShouldDelayLoad; 433 } 434 435 /** 436 * Called by the consumer of this view to delay loading images 437 */ onDelayLoading()438 public void onDelayLoading() { 439 // Don't need to explicitly tell the AsyncImageView to stop loading since 440 // ImageRequests are not cancellable. 441 mShouldDelayLoad = true; 442 } 443 444 /** 445 * Called by the consumer of this view to resume loading images 446 */ onResumeLoading()447 public void onResumeLoading() { 448 if (mShouldDelayLoad) { 449 mShouldDelayLoad = false; 450 451 // Notify all attached views to resume loading. 452 for (final AsyncImageView view : mAttachedViews) { 453 view.resumeLoading(); 454 } 455 mAttachedViews.clear(); 456 } 457 } 458 } 459 } 460