Home | History | Annotate | Download | only in ui
      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 android.support.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 
    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      */
    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         }
    129         setImage(null);
    130         resetTransientViewStates();
    131         if (!TextUtils.isEmpty(requestKey)) {
    132             maybeSetupPlaceholderDrawable(descriptor);
    133             final BindableMediaRequest<ImageResource> imageRequest =
    134                     descriptor.buildAsyncMediaRequest(getContext(), this);
    135             requestImage(imageRequest);
    136         }
    137     }
    138 
    139     /**
    140      * Sets a delay loader that centrally manages image request delay loading logic.
    141      */
    142     public void setDelayLoader(final AsyncImageViewDelayLoader delayLoader) {
    143         Assert.isTrue(mDelayLoader == null);
    144         mDelayLoader = delayLoader;
    145     }
    146 
    147     /**
    148      * Called by the delay loader when we can resume image loading.
    149      */
    150     public void resumeLoading() {
    151         Assert.notNull(mDelayLoader);
    152         Assert.isTrue(mImageRequestBinding.isBound());
    153         MediaResourceManager.get().requestMediaResourceAsync(mImageRequestBinding.getData());
    154     }
    155 
    156     /**
    157      * Setup the placeholder drawable if:
    158      * 1. There's an image to be loaded AND
    159      * 2. We are given a placeholder drawable AND
    160      * 3. The descriptor provided us with source width and height.
    161      */
    162     private void maybeSetupPlaceholderDrawable(final ImageRequestDescriptor descriptor) {
    163         if (!TextUtils.isEmpty(descriptor.getKey()) && mPlaceholderDrawable != null) {
    164             if (descriptor.sourceWidth != ImageRequest.UNSPECIFIED_SIZE &&
    165                 descriptor.sourceHeight != ImageRequest.UNSPECIFIED_SIZE) {
    166                 // Set a transparent inset drawable to the foreground so it will mimick the final
    167                 // size of the image, and use the background to show the actual placeholder
    168                 // drawable.
    169                 setImageDrawable(PlaceholderInsetDrawable.fromDrawable(
    170                         new ColorDrawable(Color.TRANSPARENT),
    171                         descriptor.sourceWidth, descriptor.sourceHeight));
    172             }
    173             setBackground(mPlaceholderDrawable);
    174         }
    175     }
    176 
    177     protected void setImage(final ImageResource resource) {
    178         setImage(resource, false /* isCached */);
    179     }
    180 
    181     protected void setImage(final ImageResource resource, final boolean isCached) {
    182         // Switch reference to the new ImageResource. Make sure we release the current
    183         // resource and addRef() on the new resource so that the underlying bitmaps don't
    184         // get leaked or get recycled by the bitmap cache.
    185         releaseImageResource();
    186         // Ensure that any pending dispose runnables get removed.
    187         ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable);
    188         // The drawable may require work to get if its a static object so try to only make this call
    189         // once.
    190         final Drawable drawable = (resource != null) ? resource.getDrawable(getResources()) : null;
    191         if (drawable != null) {
    192             mImageResource = resource;
    193             mImageResource.addRef();
    194             setImageDrawable(drawable);
    195             if (drawable instanceof FrameSequenceDrawable) {
    196                 ((FrameSequenceDrawable) drawable).start();
    197             }
    198 
    199             if (getVisibility() == VISIBLE) {
    200                 if (mReveal) {
    201                     setVisibility(INVISIBLE);
    202                     UiUtils.revealOrHideViewWithAnimation(this, VISIBLE, null);
    203                 } else if (mFadeIn && !isCached) {
    204                     // Hide initially to avoid flash.
    205                     setAlpha(0F);
    206                     animate().alpha(1F).start();
    207                 }
    208             }
    209 
    210             if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
    211                 if (mImageResource instanceof GifImageResource) {
    212                     LogUtil.v(TAG, "setImage size unknown -- it's a GIF");
    213                 } else {
    214                     LogUtil.v(TAG, "setImage size: " + mImageResource.getMediaSize() +
    215                             " width: " + mImageResource.getBitmap().getWidth() +
    216                             " heigh: " + mImageResource.getBitmap().getHeight());
    217                 }
    218             }
    219         }
    220         invalidate();
    221     }
    222 
    223     private void requestImage(final BindableMediaRequest<ImageResource> request) {
    224         mImageRequestBinding.bind(request);
    225         if (mDelayLoader == null || !mDelayLoader.isDelayLoadingImage()) {
    226             MediaResourceManager.get().requestMediaResourceAsync(request);
    227         } else {
    228             mDelayLoader.registerView(this);
    229         }
    230     }
    231 
    232     @Override
    233     public void onMediaResourceLoaded(final MediaRequest<ImageResource> request,
    234             final ImageResource resource, final boolean isCached) {
    235         if (mImageResource != resource) {
    236             setImage(resource, isCached);
    237         }
    238     }
    239 
    240     @Override
    241     public void onMediaResourceLoadError(
    242             final MediaRequest<ImageResource> request, final Exception exception) {
    243         // Media load failed, unbind and reset bitmap to default.
    244         unbindView();
    245         setImage(null);
    246     }
    247 
    248     private void releaseImageResource() {
    249         final Drawable drawable = getDrawable();
    250         if (drawable instanceof FrameSequenceDrawable) {
    251             ((FrameSequenceDrawable) drawable).stop();
    252             ((FrameSequenceDrawable) drawable).destroy();
    253         }
    254         if (mImageResource != null) {
    255             mImageResource.release();
    256             mImageResource = null;
    257         }
    258         setImageDrawable(null);
    259         setBackground(null);
    260     }
    261 
    262     /**
    263      * Resets transient view states (eg. alpha, animations) before rebinding/reusing the view.
    264      */
    265     private void resetTransientViewStates() {
    266         clearAnimation();
    267         setAlpha(1F);
    268     }
    269 
    270     @Override
    271     protected void onAttachedToWindow() {
    272         super.onAttachedToWindow();
    273         // If it was recently removed, then cancel disposing, we're still using it.
    274         ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable);
    275 
    276         // When the image view gets detached and immediately re-attached, any fade-in animation
    277         // will be terminated, leaving the view in a semi-transparent state. Make sure we restore
    278         // alpha when the view is re-attached.
    279         if (mFadeIn) {
    280             setAlpha(1F);
    281         }
    282 
    283         // Check whether we are in a simple reuse scenario: detached from window, and reattached
    284         // later without rebinding. This may be done by containers such as the RecyclerView to
    285         // reuse the views. In this case, we would like to rebind the original image request.
    286         if (!mImageRequestBinding.isBound() && mDetachedRequestDescriptor != null) {
    287             setImageResourceId(mDetachedRequestDescriptor);
    288         }
    289         mDetachedRequestDescriptor = null;
    290     }
    291 
    292     @Override
    293     protected void onDetachedFromWindow() {
    294         super.onDetachedFromWindow();
    295         // Dispose the bitmap, but if an AysncImageView is removed from the window, then quickly
    296         // re-added, we shouldn't dispose, so wait a short time before disposing
    297         ThreadUtil.getMainThreadHandler().postDelayed(mDisposeRunnable, DISPOSE_IMAGE_DELAY);
    298     }
    299 
    300     @Override
    301     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    302         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    303 
    304         // The base implementation does not honor the minimum sizes. We try to to honor it here.
    305 
    306         final int measuredWidth = getMeasuredWidth();
    307         final int measuredHeight = getMeasuredHeight();
    308         if (measuredWidth >= getMinimumWidth() || measuredHeight >= getMinimumHeight()) {
    309             // We are ok if either of the minimum sizes is honored. Note that satisfying both the
    310             // sizes may not be possible, depending on the aspect ratio of the image and whether
    311             // a maximum size has been specified. This implementation only tries to handle the case
    312             // where both the minimum sizes are not being satisfied.
    313             return;
    314         }
    315 
    316         if (!getAdjustViewBounds()) {
    317             // The base implementation is reasonable in this case. If the view bounds cannot be
    318             // changed, it is not possible to satisfy the minimum sizes anyway.
    319             return;
    320         }
    321 
    322         final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    323         final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    324         if (widthSpecMode == MeasureSpec.EXACTLY && heightSpecMode == MeasureSpec.EXACTLY) {
    325             // The base implementation is reasonable in this case.
    326             return;
    327         }
    328 
    329         int width = measuredWidth;
    330         int height = measuredHeight;
    331         // Get the minimum sizes that will honor other constraints as well.
    332         final int minimumWidth = resolveSize(
    333                 getMinimumWidth(), getMaxWidth(), widthMeasureSpec);
    334         final int minimumHeight = resolveSize(
    335                 getMinimumHeight(), getMaxHeight(), heightMeasureSpec);
    336         final float aspectRatio = measuredWidth / (float) measuredHeight;
    337         if (aspectRatio == 0) {
    338             // If the image is (close to) infinitely high, there is not much we can do.
    339             return;
    340         }
    341 
    342         if (width < minimumWidth) {
    343             height = resolveSize((int) (minimumWidth / aspectRatio),
    344                     getMaxHeight(), heightMeasureSpec);
    345             width = (int) (height * aspectRatio);
    346         }
    347 
    348         if (height < minimumHeight) {
    349             width = resolveSize((int) (minimumHeight * aspectRatio),
    350                     getMaxWidth(), widthMeasureSpec);
    351             height = (int) (width / aspectRatio);
    352         }
    353 
    354         setMeasuredDimension(width, height);
    355     }
    356 
    357     private static int resolveSize(int desiredSize, int maxSize, int measureSpec) {
    358         final int specMode = MeasureSpec.getMode(measureSpec);
    359         final int specSize =  MeasureSpec.getSize(measureSpec);
    360         switch(specMode) {
    361             case MeasureSpec.UNSPECIFIED:
    362                 return Math.min(desiredSize, maxSize);
    363 
    364             case MeasureSpec.AT_MOST:
    365                 return Math.min(Math.min(desiredSize, specSize), maxSize);
    366 
    367             default:
    368                 Assert.fail("Unreachable");
    369                 return specSize;
    370         }
    371     }
    372 
    373     @Override
    374     protected void onDraw(final Canvas canvas) {
    375         if (mCornerRadius > 0) {
    376             final int currentWidth = this.getWidth();
    377             final int currentHeight = this.getHeight();
    378             if (mClipPathWidth != currentWidth || mClipPathHeight != currentHeight) {
    379                 final RectF rect = new RectF(0, 0, currentWidth, currentHeight);
    380                 mRoundedCornerClipPath.reset();
    381                 mRoundedCornerClipPath.addRoundRect(rect, mCornerRadius, mCornerRadius,
    382                         Path.Direction.CW);
    383                 mClipPathWidth = currentWidth;
    384                 mClipPathHeight = currentHeight;
    385             }
    386 
    387             final int saveCount = canvas.getSaveCount();
    388             canvas.save();
    389             canvas.clipPath(mRoundedCornerClipPath);
    390             super.onDraw(canvas);
    391             canvas.restoreToCount(saveCount);
    392         } else {
    393             super.onDraw(canvas);
    394         }
    395     }
    396 
    397     private void unbindView() {
    398         if (mImageRequestBinding.isBound()) {
    399             mImageRequestBinding.unbind();
    400             if (mDelayLoader != null) {
    401                 mDelayLoader.unregisterView(this);
    402             }
    403         }
    404     }
    405 
    406     /**
    407      * As a performance optimization, the consumer of the AsyncImageView may opt to delay loading
    408      * the image when it's busy doing other things (such as when a list view is scrolling). In
    409      * order to do this, the consumer can create a new AsyncImageViewDelayLoader instance to be
    410      * shared among all relevant AsyncImageViews (through setDelayLoader() method), and call
    411      * onStartDelayLoading() and onStopDelayLoading() to start and stop delay loading, respectively.
    412      */
    413     public static class AsyncImageViewDelayLoader {
    414         private boolean mShouldDelayLoad;
    415         private final HashSet<AsyncImageView> mAttachedViews;
    416 
    417         public AsyncImageViewDelayLoader() {
    418             mAttachedViews = new HashSet<AsyncImageView>();
    419         }
    420 
    421         private void registerView(final AsyncImageView view) {
    422             mAttachedViews.add(view);
    423         }
    424 
    425         private void unregisterView(final AsyncImageView view) {
    426             mAttachedViews.remove(view);
    427         }
    428 
    429         public boolean isDelayLoadingImage() {
    430             return mShouldDelayLoad;
    431         }
    432 
    433         /**
    434          * Called by the consumer of this view to delay loading images
    435          */
    436         public void onDelayLoading() {
    437             // Don't need to explicitly tell the AsyncImageView to stop loading since
    438             // ImageRequests are not cancellable.
    439             mShouldDelayLoad = true;
    440         }
    441 
    442         /**
    443          * Called by the consumer of this view to resume loading images
    444          */
    445         public void onResumeLoading() {
    446             if (mShouldDelayLoad) {
    447                 mShouldDelayLoad = false;
    448 
    449                 // Notify all attached views to resume loading.
    450                 for (final AsyncImageView view : mAttachedViews) {
    451                     view.resumeLoading();
    452                 }
    453                 mAttachedViews.clear();
    454             }
    455         }
    456     }
    457 }
    458