blob: 7d057b3af7e198cd3025718700cffefac63b9387 [file] [log] [blame]
/*
* 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();
}
}
}
}