/* * Copyright (C) 2015 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.messaging.ui; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Path; import android.graphics.RectF; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import androidx.annotation.Nullable; import android.support.rastermill.FrameSequenceDrawable; import android.text.TextUtils; import android.util.AttributeSet; import android.widget.ImageView; import com.android.messaging.R; import com.android.messaging.datamodel.binding.Binding; import com.android.messaging.datamodel.binding.BindingBase; import com.android.messaging.datamodel.media.BindableMediaRequest; import com.android.messaging.datamodel.media.GifImageResource; import com.android.messaging.datamodel.media.ImageRequest; import com.android.messaging.datamodel.media.ImageRequestDescriptor; import com.android.messaging.datamodel.media.ImageResource; import com.android.messaging.datamodel.media.MediaRequest; import com.android.messaging.datamodel.media.MediaResourceManager; import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; import com.android.messaging.util.Assert; import com.android.messaging.util.LogUtil; import com.android.messaging.util.ThreadUtil; import com.android.messaging.util.UiUtils; import com.google.common.annotations.VisibleForTesting; import java.util.HashSet; /** * An ImageView used to asynchronously request an image from MediaResourceManager and render it. */ public class AsyncImageView extends ImageView implements MediaResourceLoadListener { private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; // 100ms delay before disposing the image in case the AsyncImageView is re-added to the UI private static final int DISPOSE_IMAGE_DELAY = 100; // AsyncImageView has a 1-1 binding relationship with an ImageRequest instance that requests // the image from the MediaResourceManager. Since the request is done asynchronously, we // want to make sure the image view is always bound to the latest image request that it // issues, so that when the image is loaded, the ImageRequest (which extends BindableData) // will be able to figure out whether the binding is still valid and whether the loaded image // should be delivered to the AsyncImageView via onMediaResourceLoaded() callback. @VisibleForTesting public final Binding> mImageRequestBinding; /** True if we want the image to fade in when it loads */ private boolean mFadeIn; /** True if we want the image to reveal (scale) when it loads. When set to true, this * will take precedence over {@link #mFadeIn} */ private final boolean mReveal; // The corner radius for drawing rounded corners around bitmap. The default value is zero // (no rounded corners) private final int mCornerRadius; private final Path mRoundedCornerClipPath; private int mClipPathWidth; private int mClipPathHeight; // A placeholder drawable that takes the spot of the image when it's loading. The default // setting is null (no placeholder). private final Drawable mPlaceholderDrawable; protected ImageResource mImageResource; private final Runnable mDisposeRunnable = new Runnable() { @Override public void run() { if (mImageRequestBinding.isBound()) { mDetachedRequestDescriptor = (ImageRequestDescriptor) mImageRequestBinding.getData().getDescriptor(); } unbindView(); releaseImageResource(); } }; private AsyncImageViewDelayLoader mDelayLoader; private ImageRequestDescriptor mDetachedRequestDescriptor; public AsyncImageView(final Context context, final AttributeSet attrs) { super(context, attrs); mImageRequestBinding = BindingBase.createBinding(this); final TypedArray attr = context.obtainStyledAttributes(attrs, R.styleable.AsyncImageView, 0, 0); mFadeIn = attr.getBoolean(R.styleable.AsyncImageView_fadeIn, true); mReveal = attr.getBoolean(R.styleable.AsyncImageView_reveal, false); mPlaceholderDrawable = attr.getDrawable(R.styleable.AsyncImageView_placeholderDrawable); mCornerRadius = attr.getDimensionPixelSize(R.styleable.AsyncImageView_cornerRadius, 0); mRoundedCornerClipPath = new Path(); attr.recycle(); } /** * The main entrypoint for AsyncImageView to load image resource given an ImageRequestDescriptor * @param descriptor the request descriptor, or null if no image should be displayed */ public void setImageResourceId(@Nullable final ImageRequestDescriptor descriptor) { final String requestKey = (descriptor == null) ? null : descriptor.getKey(); if (mImageRequestBinding.isBound()) { if (TextUtils.equals(mImageRequestBinding.getData().getKey(), requestKey)) { // Don't re-request the bitmap if the new request is for the same resource. return; } unbindView(); } else { mDetachedRequestDescriptor = null; } setImage(null); resetTransientViewStates(); if (!TextUtils.isEmpty(requestKey)) { maybeSetupPlaceholderDrawable(descriptor); final BindableMediaRequest imageRequest = descriptor.buildAsyncMediaRequest(getContext(), this); requestImage(imageRequest); } } /** * Sets a delay loader that centrally manages image request delay loading logic. */ public void setDelayLoader(final AsyncImageViewDelayLoader delayLoader) { Assert.isTrue(mDelayLoader == null); mDelayLoader = delayLoader; } /** * Called by the delay loader when we can resume image loading. */ public void resumeLoading() { Assert.notNull(mDelayLoader); Assert.isTrue(mImageRequestBinding.isBound()); MediaResourceManager.get().requestMediaResourceAsync(mImageRequestBinding.getData()); } /** * Setup the placeholder drawable if: * 1. There's an image to be loaded AND * 2. We are given a placeholder drawable AND * 3. The descriptor provided us with source width and height. */ private void maybeSetupPlaceholderDrawable(final ImageRequestDescriptor descriptor) { if (!TextUtils.isEmpty(descriptor.getKey()) && mPlaceholderDrawable != null) { if (descriptor.sourceWidth != ImageRequest.UNSPECIFIED_SIZE && descriptor.sourceHeight != ImageRequest.UNSPECIFIED_SIZE) { // Set a transparent inset drawable to the foreground so it will mimick the final // size of the image, and use the background to show the actual placeholder // drawable. setImageDrawable(PlaceholderInsetDrawable.fromDrawable( new ColorDrawable(Color.TRANSPARENT), descriptor.sourceWidth, descriptor.sourceHeight)); } setBackground(mPlaceholderDrawable); } } protected void setImage(final ImageResource resource) { setImage(resource, false /* isCached */); } protected void setImage(final ImageResource resource, final boolean isCached) { // Switch reference to the new ImageResource. Make sure we release the current // resource and addRef() on the new resource so that the underlying bitmaps don't // get leaked or get recycled by the bitmap cache. releaseImageResource(); // Ensure that any pending dispose runnables get removed. ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable); // The drawable may require work to get if its a static object so try to only make this call // once. final Drawable drawable = (resource != null) ? resource.getDrawable(getResources()) : null; if (drawable != null) { mImageResource = resource; mImageResource.addRef(); setImageDrawable(drawable); if (drawable instanceof FrameSequenceDrawable) { ((FrameSequenceDrawable) drawable).start(); } if (getVisibility() == VISIBLE) { if (mReveal) { setVisibility(INVISIBLE); UiUtils.revealOrHideViewWithAnimation(this, VISIBLE, null); } else if (mFadeIn && !isCached) { // Hide initially to avoid flash. setAlpha(0F); animate().alpha(1F).start(); } } if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { if (mImageResource instanceof GifImageResource) { LogUtil.v(TAG, "setImage size unknown -- it's a GIF"); } else { LogUtil.v(TAG, "setImage size: " + mImageResource.getMediaSize() + " width: " + mImageResource.getBitmap().getWidth() + " heigh: " + mImageResource.getBitmap().getHeight()); } } } invalidate(); } private void requestImage(final BindableMediaRequest request) { mImageRequestBinding.bind(request); if (mDelayLoader == null || !mDelayLoader.isDelayLoadingImage()) { MediaResourceManager.get().requestMediaResourceAsync(request); } else { mDelayLoader.registerView(this); } } @Override public void onMediaResourceLoaded(final MediaRequest request, final ImageResource resource, final boolean isCached) { if (mImageResource != resource) { setImage(resource, isCached); } } @Override public void onMediaResourceLoadError( final MediaRequest request, final Exception exception) { // Media load failed, unbind and reset bitmap to default. unbindView(); setImage(null); } private void releaseImageResource() { final Drawable drawable = getDrawable(); if (drawable instanceof FrameSequenceDrawable) { ((FrameSequenceDrawable) drawable).stop(); ((FrameSequenceDrawable) drawable).destroy(); } if (mImageResource != null) { mImageResource.release(); mImageResource = null; } setImageDrawable(null); setBackground(null); } /** * Resets transient view states (eg. alpha, animations) before rebinding/reusing the view. */ private void resetTransientViewStates() { clearAnimation(); setAlpha(1F); } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); // If it was recently removed, then cancel disposing, we're still using it. ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable); // When the image view gets detached and immediately re-attached, any fade-in animation // will be terminated, leaving the view in a semi-transparent state. Make sure we restore // alpha when the view is re-attached. if (mFadeIn) { setAlpha(1F); } // Check whether we are in a simple reuse scenario: detached from window, and reattached // later without rebinding. This may be done by containers such as the RecyclerView to // reuse the views. In this case, we would like to rebind the original image request. if (!mImageRequestBinding.isBound() && mDetachedRequestDescriptor != null) { setImageResourceId(mDetachedRequestDescriptor); } mDetachedRequestDescriptor = null; } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); // Dispose the bitmap, but if an AysncImageView is removed from the window, then quickly // re-added, we shouldn't dispose, so wait a short time before disposing ThreadUtil.getMainThreadHandler().postDelayed(mDisposeRunnable, DISPOSE_IMAGE_DELAY); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); // The base implementation does not honor the minimum sizes. We try to to honor it here. final int measuredWidth = getMeasuredWidth(); final int measuredHeight = getMeasuredHeight(); if (measuredWidth >= getMinimumWidth() || measuredHeight >= getMinimumHeight()) { // We are ok if either of the minimum sizes is honored. Note that satisfying both the // sizes may not be possible, depending on the aspect ratio of the image and whether // a maximum size has been specified. This implementation only tries to handle the case // where both the minimum sizes are not being satisfied. return; } if (!getAdjustViewBounds()) { // The base implementation is reasonable in this case. If the view bounds cannot be // changed, it is not possible to satisfy the minimum sizes anyway. return; } final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); if (widthSpecMode == MeasureSpec.EXACTLY && heightSpecMode == MeasureSpec.EXACTLY) { // The base implementation is reasonable in this case. return; } int width = measuredWidth; int height = measuredHeight; // Get the minimum sizes that will honor other constraints as well. final int minimumWidth = resolveSize( getMinimumWidth(), getMaxWidth(), widthMeasureSpec); final int minimumHeight = resolveSize( getMinimumHeight(), getMaxHeight(), heightMeasureSpec); final float aspectRatio = measuredWidth / (float) measuredHeight; if (aspectRatio == 0) { // If the image is (close to) infinitely high, there is not much we can do. return; } if (width < minimumWidth) { height = resolveSize((int) (minimumWidth / aspectRatio), getMaxHeight(), heightMeasureSpec); width = (int) (height * aspectRatio); } if (height < minimumHeight) { width = resolveSize((int) (minimumHeight * aspectRatio), getMaxWidth(), widthMeasureSpec); height = (int) (width / aspectRatio); } setMeasuredDimension(width, height); } private static int resolveSize(int desiredSize, int maxSize, int measureSpec) { final int specMode = MeasureSpec.getMode(measureSpec); final int specSize = MeasureSpec.getSize(measureSpec); switch(specMode) { case MeasureSpec.UNSPECIFIED: return Math.min(desiredSize, maxSize); case MeasureSpec.AT_MOST: return Math.min(Math.min(desiredSize, specSize), maxSize); default: Assert.fail("Unreachable"); return specSize; } } @Override protected void onDraw(final Canvas canvas) { if (mCornerRadius > 0) { final int currentWidth = this.getWidth(); final int currentHeight = this.getHeight(); if (mClipPathWidth != currentWidth || mClipPathHeight != currentHeight) { final RectF rect = new RectF(0, 0, currentWidth, currentHeight); mRoundedCornerClipPath.reset(); mRoundedCornerClipPath.addRoundRect(rect, mCornerRadius, mCornerRadius, Path.Direction.CW); mClipPathWidth = currentWidth; mClipPathHeight = currentHeight; } final int saveCount = canvas.getSaveCount(); canvas.save(); canvas.clipPath(mRoundedCornerClipPath); super.onDraw(canvas); canvas.restoreToCount(saveCount); } else { super.onDraw(canvas); } } private void unbindView() { if (mImageRequestBinding.isBound()) { mImageRequestBinding.unbind(); if (mDelayLoader != null) { mDelayLoader.unregisterView(this); } } } /** * As a performance optimization, the consumer of the AsyncImageView may opt to delay loading * the image when it's busy doing other things (such as when a list view is scrolling). In * order to do this, the consumer can create a new AsyncImageViewDelayLoader instance to be * shared among all relevant AsyncImageViews (through setDelayLoader() method), and call * onStartDelayLoading() and onStopDelayLoading() to start and stop delay loading, respectively. */ public static class AsyncImageViewDelayLoader { private boolean mShouldDelayLoad; private final HashSet mAttachedViews; public AsyncImageViewDelayLoader() { mAttachedViews = new HashSet(); } private void registerView(final AsyncImageView view) { mAttachedViews.add(view); } private void unregisterView(final AsyncImageView view) { mAttachedViews.remove(view); } public boolean isDelayLoadingImage() { return mShouldDelayLoad; } /** * Called by the consumer of this view to delay loading images */ public void onDelayLoading() { // Don't need to explicitly tell the AsyncImageView to stop loading since // ImageRequests are not cancellable. mShouldDelayLoad = true; } /** * Called by the consumer of this view to resume loading images */ public void onResumeLoading() { if (mShouldDelayLoad) { mShouldDelayLoad = false; // Notify all attached views to resume loading. for (final AsyncImageView view : mAttachedViews) { view.resumeLoading(); } mAttachedViews.clear(); } } } }