| /* |
| * 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<ImageResource> { |
| 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<BindableMediaRequest<ImageResource>> 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<ImageResource> 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<ImageResource> request) { |
| mImageRequestBinding.bind(request); |
| if (mDelayLoader == null || !mDelayLoader.isDelayLoadingImage()) { |
| MediaResourceManager.get().requestMediaResourceAsync(request); |
| } else { |
| mDelayLoader.registerView(this); |
| } |
| } |
| |
| @Override |
| public void onMediaResourceLoaded(final MediaRequest<ImageResource> request, |
| final ImageResource resource, final boolean isCached) { |
| if (mImageResource != resource) { |
| setImage(resource, isCached); |
| } |
| } |
| |
| @Override |
| public void onMediaResourceLoadError( |
| final MediaRequest<ImageResource> 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<AsyncImageView> mAttachedViews; |
| |
| public AsyncImageViewDelayLoader() { |
| mAttachedViews = new HashSet<AsyncImageView>(); |
| } |
| |
| 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(); |
| } |
| } |
| } |
| } |