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.graphics.Rect;
21 import android.util.AttributeSet;
22 import android.view.LayoutInflater;
23 import android.view.View;
24 import android.view.animation.AnimationSet;
25 import android.view.animation.ScaleAnimation;
26 import android.view.animation.TranslateAnimation;
27 import android.widget.FrameLayout;
28 import android.widget.TextView;
29 
30 import com.android.messaging.R;
31 import com.android.messaging.datamodel.data.MediaPickerMessagePartData;
32 import com.android.messaging.datamodel.data.MessagePartData;
33 import com.android.messaging.datamodel.data.PendingAttachmentData;
34 import com.android.messaging.datamodel.media.ImageRequestDescriptor;
35 import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader;
36 import com.android.messaging.ui.animation.PopupTransitionAnimation;
37 import com.android.messaging.util.AccessibilityUtil;
38 import com.android.messaging.util.Assert;
39 import com.android.messaging.util.UiUtils;
40 
41 import java.util.ArrayList;
42 import java.util.Arrays;
43 import java.util.Iterator;
44 import java.util.List;
45 
46 /**
47  * Holds and displays multiple attachments in a 4x2 grid. Each preview image "tile" can take
48  * one of three sizes - small (1x1), wide (2x1) and large (2x2). We have a number of predefined
49  * layout settings designed for holding 2, 3, 4+ attachments (these layout settings are
50  * tweakable by design request to allow for max flexibility). For a visual example, consider the
51  * following attachment layout:
52  *
53  * +---------------+----------------+
54  * |               |                |
55  * |               |       B        |
56  * |               |                |
57  * |       A       |-------+--------|
58  * |               |       |        |
59  * |               |   C   |    D   |
60  * |               |       |        |
61  * +---------------+-------+--------+
62  *
63  * In the above example, the layout consists of four tiles, A-D. A is a large tile, B is a
64  * wide tile and C & D are both small tiles. A starts at (0,0) and ends at (1,1), B starts at
65  * (2,0) and ends at (3,0), and so on. In our layout class we'd have these tiles in the order
66  * of A-D, so that we make sure the last tile is always the one where we can put the overflow
67  * indicator (e.g. "+2").
68  */
69 public class MultiAttachmentLayout extends FrameLayout {
70 
71     public interface OnAttachmentClickListener {
onAttachmentClick(MessagePartData attachment, Rect viewBoundsOnScreen, boolean longPress)72         boolean onAttachmentClick(MessagePartData attachment, Rect viewBoundsOnScreen,
73                 boolean longPress);
74     }
75 
76     private static final int GRID_WIDTH = 4;    // in # of cells
77     private static final int GRID_HEIGHT = 2;   // in # of cells
78 
79     /**
80      * Represents a preview image tile in the layout
81      */
82     private static class Tile {
83         public final int startX;
84         public final int startY;
85         public final int endX;
86         public final int endY;
87 
Tile(final int startX, final int startY, final int endX, final int endY)88         private Tile(final int startX, final int startY, final int endX, final int endY) {
89             this.startX = startX;
90             this.startY = startY;
91             this.endX = endX;
92             this.endY = endY;
93         }
94 
getWidthMeasureSpec(final int cellWidth, final int padding)95         public int getWidthMeasureSpec(final int cellWidth, final int padding) {
96             return MeasureSpec.makeMeasureSpec((endX - startX + 1) * cellWidth - padding * 2,
97                     MeasureSpec.EXACTLY);
98         }
99 
getHeightMeasureSpec(final int cellHeight, final int padding)100         public int getHeightMeasureSpec(final int cellHeight, final int padding) {
101             return MeasureSpec.makeMeasureSpec((endY - startY + 1) * cellHeight - padding * 2,
102                     MeasureSpec.EXACTLY);
103         }
104 
large(final int startX, final int startY)105         public static Tile large(final int startX, final int startY) {
106             return new Tile(startX, startY, startX + 1, startY + 1);
107         }
108 
wide(final int startX, final int startY)109         public static Tile wide(final int startX, final int startY) {
110             return new Tile(startX, startY, startX + 1, startY);
111         }
112 
small(final int startX, final int startY)113         public static Tile small(final int startX, final int startY) {
114             return new Tile(startX, startY, startX, startY);
115         }
116     }
117 
118     /**
119      * A layout simply contains a list of tiles, in the order of top-left -> bottom-right.
120      */
121     private static class Layout {
122         public final List<Tile> tiles;
Layout(final Tile[] tilesArray)123         public Layout(final Tile[] tilesArray) {
124             tiles = Arrays.asList(tilesArray);
125         }
126     }
127 
128     /**
129      * List of predefined layout configurations w.r.t no. of attachments.
130      */
131     private static final Layout[] ATTACHMENT_LAYOUTS_BY_COUNT = {
132         null,   // Doesn't support zero attachments.
133         null,   // Doesn't support one attachment. Single attachment preview is used instead.
134         new Layout(new Tile[] { Tile.large(0, 0), Tile.large(2, 0) }),                  // 2 items
135         new Layout(new Tile[] { Tile.large(0, 0), Tile.wide(2, 0), Tile.wide(2, 1) }),  // 3 items
136         new Layout(new Tile[] { Tile.large(0, 0), Tile.wide(2, 0), Tile.small(2, 1),    // 4+ items
137                 Tile.small(3, 1) }),
138     };
139 
140     /**
141      * List of predefined RTL layout configurations w.r.t no. of attachments.
142      */
143     private static final Layout[] ATTACHMENT_RTL_LAYOUTS_BY_COUNT = {
144         null,   // Doesn't support zero attachments.
145         null,   // Doesn't support one attachment. Single attachment preview is used instead.
146         new Layout(new Tile[] { Tile.large(2, 0), Tile.large(0, 0)}),                   // 2 items
147         new Layout(new Tile[] { Tile.large(2, 0), Tile.wide(0, 0), Tile.wide(0, 1) }),  // 3 items
148         new Layout(new Tile[] { Tile.large(2, 0), Tile.wide(0, 0), Tile.small(1, 1),    // 4+ items
149                 Tile.small(0, 1) }),
150     };
151 
152     private Layout mCurrentLayout;
153     private ArrayList<ViewWrapper> mPreviewViews;
154     private int mPlusNumber;
155     private TextView mPlusTextView;
156     private OnAttachmentClickListener mAttachmentClickListener;
157     private AsyncImageViewDelayLoader mImageViewDelayLoader;
158 
MultiAttachmentLayout(final Context context, final AttributeSet attrs)159     public MultiAttachmentLayout(final Context context, final AttributeSet attrs) {
160         super(context, attrs);
161         mPreviewViews = new ArrayList<ViewWrapper>();
162     }
163 
bindAttachments(final Iterable<MessagePartData> attachments, final Rect transitionRect, final int count)164     public void bindAttachments(final Iterable<MessagePartData> attachments,
165             final Rect transitionRect, final int count) {
166         final ArrayList<ViewWrapper> previousViews = mPreviewViews;
167         mPreviewViews = new ArrayList<ViewWrapper>();
168         removeView(mPlusTextView);
169         mPlusTextView = null;
170 
171         determineLayout(attachments, count);
172         buildViews(attachments, previousViews, transitionRect);
173 
174         // Remove all previous views that couldn't be recycled.
175         for (final ViewWrapper viewWrapper : previousViews) {
176             removeView(viewWrapper.view);
177         }
178         requestLayout();
179     }
180 
getOnAttachmentClickListener()181     public OnAttachmentClickListener getOnAttachmentClickListener() {
182         return mAttachmentClickListener;
183     }
184 
setOnAttachmentClickListener(final OnAttachmentClickListener listener)185     public void setOnAttachmentClickListener(final OnAttachmentClickListener listener) {
186         mAttachmentClickListener = listener;
187     }
188 
setImageViewDelayLoader(final AsyncImageViewDelayLoader delayLoader)189     public void setImageViewDelayLoader(final AsyncImageViewDelayLoader delayLoader) {
190         mImageViewDelayLoader = delayLoader;
191     }
192 
setColorFilter(int color)193     public void setColorFilter(int color) {
194         for (ViewWrapper viewWrapper : mPreviewViews) {
195             if (viewWrapper.view instanceof AsyncImageView) {
196                 ((AsyncImageView) viewWrapper.view).setColorFilter(color);
197             }
198         }
199     }
200 
clearColorFilter()201     public void clearColorFilter() {
202         for (ViewWrapper viewWrapper : mPreviewViews) {
203             if (viewWrapper.view instanceof AsyncImageView) {
204                 ((AsyncImageView) viewWrapper.view).clearColorFilter();
205             }
206         }
207     }
208 
determineLayout(final Iterable<MessagePartData> attachments, final int count)209     private void determineLayout(final Iterable<MessagePartData> attachments, final int count) {
210         Assert.isTrue(attachments != null);
211         final boolean isRtl = AccessibilityUtil.isLayoutRtl(getRootView());
212         if (isRtl) {
213             mCurrentLayout = ATTACHMENT_RTL_LAYOUTS_BY_COUNT[Math.min(count,
214                     ATTACHMENT_RTL_LAYOUTS_BY_COUNT.length - 1)];
215         } else {
216             mCurrentLayout = ATTACHMENT_LAYOUTS_BY_COUNT[Math.min(count,
217                     ATTACHMENT_LAYOUTS_BY_COUNT.length - 1)];
218         }
219 
220         // We must have a valid layout for the current configuration.
221         Assert.notNull(mCurrentLayout);
222 
223         mPlusNumber = count - mCurrentLayout.tiles.size();
224         Assert.isTrue(mPlusNumber >= 0);
225     }
226 
buildViews(final Iterable<MessagePartData> attachments, final ArrayList<ViewWrapper> previousViews, final Rect transitionRect)227     private void buildViews(final Iterable<MessagePartData> attachments,
228             final ArrayList<ViewWrapper> previousViews, final Rect transitionRect) {
229         final LayoutInflater layoutInflater = LayoutInflater.from(getContext());
230         final int count = mCurrentLayout.tiles.size();
231         int i = 0;
232         final Iterator<MessagePartData> iterator = attachments.iterator();
233         while (iterator.hasNext() && i < count) {
234             final MessagePartData attachment = iterator.next();
235             ViewWrapper attachmentWrapper = null;
236             // Try to recycle a previous view first
237             for (int j = 0; j < previousViews.size(); j++) {
238                 final ViewWrapper previousView = previousViews.get(j);
239                 if (previousView.attachment.equals(attachment) &&
240                         !(previousView.attachment instanceof PendingAttachmentData)) {
241                     attachmentWrapper = previousView;
242                     previousViews.remove(j);
243                     break;
244                 }
245             }
246 
247             if (attachmentWrapper == null) {
248                 final View view = AttachmentPreviewFactory.createAttachmentPreview(layoutInflater,
249                         attachment, this, AttachmentPreviewFactory.TYPE_MULTIPLE,
250                         false /* startImageRequest */, mAttachmentClickListener);
251 
252                 if (view == null) {
253                     // createAttachmentPreview can return null if something goes wrong (e.g.
254                     // attachment has unsupported contentType)
255                     continue;
256                 }
257                 if (view instanceof AsyncImageView && mImageViewDelayLoader != null) {
258                     AsyncImageView asyncImageView = (AsyncImageView) view;
259                     asyncImageView.setDelayLoader(mImageViewDelayLoader);
260                 }
261                 addView(view);
262                 attachmentWrapper = new ViewWrapper(view, attachment);
263                 // Help animate from single to multi by copying over the prev location
264                 if (count == 2 && i == 1 && transitionRect != null) {
265                     attachmentWrapper.prevLeft = transitionRect.left;
266                     attachmentWrapper.prevTop = transitionRect.top;
267                     attachmentWrapper.prevWidth = transitionRect.width();
268                     attachmentWrapper.prevHeight = transitionRect.height();
269                 }
270             }
271             i++;
272             Assert.notNull(attachmentWrapper);
273             mPreviewViews.add(attachmentWrapper);
274 
275             // The first view will animate in using PopupTransitionAnimation, but the remaining
276             // views will slide from their previous position to their new position within the
277             // layout
278             if (i == 0) {
279                 if (attachment instanceof MediaPickerMessagePartData) {
280                     final Rect startRect = ((MediaPickerMessagePartData) attachment).getStartRect();
281                     new PopupTransitionAnimation(startRect, attachmentWrapper.view)
282                             .startAfterLayoutComplete();
283                 }
284             }
285             attachmentWrapper.needsSlideAnimation = i > 0;
286         }
287 
288         // Build the plus text view (e.g. "+2") for when there are more attachments than what
289         // this layout can display.
290         if (mPlusNumber > 0) {
291             mPlusTextView = (TextView) layoutInflater.inflate(R.layout.attachment_more_text_view,
292                     null /* parent */);
293             mPlusTextView.setText(getResources().getString(R.string.attachment_more_items,
294                     mPlusNumber));
295             addView(mPlusTextView);
296         }
297     }
298 
299     @Override
onMeasure(final int widthMeasureSpec, final int heightMeasureSpec)300     protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
301         final int maxWidth = getResources().getDimensionPixelSize(
302                 R.dimen.multiple_attachment_preview_width);
303         final int maxHeight = getResources().getDimensionPixelSize(
304                 R.dimen.multiple_attachment_preview_height);
305         final int width = Math.min(MeasureSpec.getSize(widthMeasureSpec), maxWidth);
306         final int height = maxHeight;
307         final int cellWidth = width / GRID_WIDTH;
308         final int cellHeight = height / GRID_HEIGHT;
309         final int count = mPreviewViews.size();
310         final int padding = getResources().getDimensionPixelOffset(
311                 R.dimen.multiple_attachment_preview_padding);
312         for (int i = 0; i < count; i++) {
313             final View view =  mPreviewViews.get(i).view;
314             final Tile imageTile = mCurrentLayout.tiles.get(i);
315             view.measure(imageTile.getWidthMeasureSpec(cellWidth, padding),
316                     imageTile.getHeightMeasureSpec(cellHeight, padding));
317 
318             // Now that we know the size, we can request an appropriately-sized image.
319             if (view instanceof AsyncImageView) {
320                 final ImageRequestDescriptor imageRequest =
321                         AttachmentPreviewFactory.getImageRequestDescriptorForAttachment(
322                                 mPreviewViews.get(i).attachment,
323                                 view.getMeasuredWidth(),
324                                 view.getMeasuredHeight());
325                 ((AsyncImageView) view).setImageResourceId(imageRequest);
326             }
327 
328             if (i == count - 1 && mPlusTextView != null) {
329                 // The plus text view always covers the last attachment.
330                 mPlusTextView.measure(imageTile.getWidthMeasureSpec(cellWidth, padding),
331                         imageTile.getHeightMeasureSpec(cellHeight, padding));
332             }
333         }
334         setMeasuredDimension(width, height);
335     }
336 
337     @Override
onLayout(final boolean changed, final int left, final int top, final int right, final int bottom)338     protected void onLayout(final boolean changed, final int left, final int top, final int right,
339             final int bottom) {
340         final int cellWidth = getMeasuredWidth() / GRID_WIDTH;
341         final int cellHeight = getMeasuredHeight() / GRID_HEIGHT;
342         final int padding = getResources().getDimensionPixelOffset(
343                 R.dimen.multiple_attachment_preview_padding);
344         final int count = mPreviewViews.size();
345         for (int i = 0; i < count; i++) {
346             final ViewWrapper viewWrapper =  mPreviewViews.get(i);
347             final View view = viewWrapper.view;
348             final Tile imageTile = mCurrentLayout.tiles.get(i);
349             final int tileLeft = imageTile.startX * cellWidth;
350             final int tileTop = imageTile.startY * cellHeight;
351             view.layout(tileLeft + padding, tileTop + padding,
352                     tileLeft + view.getMeasuredWidth(),
353                     tileTop + view.getMeasuredHeight());
354             if (viewWrapper.needsSlideAnimation) {
355                 trySlideAttachmentView(viewWrapper);
356                 viewWrapper.needsSlideAnimation = false;
357             } else {
358                 viewWrapper.prevLeft = view.getLeft();
359                 viewWrapper.prevTop = view.getTop();
360                 viewWrapper.prevWidth = view.getWidth();
361                 viewWrapper.prevHeight = view.getHeight();
362             }
363 
364             if (i == count - 1 && mPlusTextView != null) {
365                 // The plus text view always covers the last attachment.
366                 mPlusTextView.layout(tileLeft + padding, tileTop + padding,
367                         tileLeft + mPlusTextView.getMeasuredWidth(),
368                         tileTop + mPlusTextView.getMeasuredHeight());
369             }
370         }
371     }
372 
trySlideAttachmentView(final ViewWrapper viewWrapper)373     private void trySlideAttachmentView(final ViewWrapper viewWrapper) {
374         if (!(viewWrapper.attachment instanceof MediaPickerMessagePartData)) {
375             return;
376         }
377         final View view = viewWrapper.view;
378 
379 
380         final int xOffset = viewWrapper.prevLeft - view.getLeft();
381         final int yOffset = viewWrapper.prevTop - view.getTop();
382         final float scaleX = viewWrapper.prevWidth / (float) view.getWidth();
383         final float scaleY = viewWrapper.prevHeight / (float) view.getHeight();
384 
385         if (xOffset == 0 && yOffset == 0 && scaleX == 1 && scaleY == 1) {
386             // Layout hasn't changed
387             return;
388         }
389 
390         final AnimationSet animationSet = new AnimationSet(
391                 true /* shareInterpolator */);
392         animationSet.addAnimation(new TranslateAnimation(xOffset, 0, yOffset, 0));
393         animationSet.addAnimation(new ScaleAnimation(scaleX, 1, scaleY, 1));
394         animationSet.setDuration(
395                 UiUtils.MEDIAPICKER_TRANSITION_DURATION);
396         animationSet.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR);
397         view.startAnimation(animationSet);
398         view.invalidate();
399         viewWrapper.prevLeft = view.getLeft();
400         viewWrapper.prevTop = view.getTop();
401         viewWrapper.prevWidth = view.getWidth();
402         viewWrapper.prevHeight = view.getHeight();
403     }
404 
findViewForAttachment(final MessagePartData attachment)405     public View findViewForAttachment(final MessagePartData attachment) {
406         for (ViewWrapper wrapper : mPreviewViews) {
407             if (wrapper.attachment.equals(attachment) &&
408                     !(wrapper.attachment instanceof PendingAttachmentData)) {
409                 return wrapper.view;
410             }
411         }
412         return null;
413     }
414 
415     private static class ViewWrapper {
416         final View view;
417         final MessagePartData attachment;
418         boolean needsSlideAnimation;
419         int prevLeft;
420         int prevTop;
421         int prevWidth;
422         int prevHeight;
423 
ViewWrapper(final View view, final MessagePartData attachment)424         ViewWrapper(final View view, final MessagePartData attachment) {
425             this.view = view;
426             this.attachment = attachment;
427         }
428     }
429 }
430