diff options
author | 2022-12-23 15:45:43 -0800 | |
---|---|---|
committer | 2022-12-28 18:50:03 +0000 | |
commit | fb2ee59060fc15a659ea859cee3466035f6b4963 (patch) | |
tree | f79bf3d12d4ee37afa52049d99d0de1e380299e4 | |
parent | 4f282042d84e40a9da7183c2d908e90c83b689ab (diff) |
Introduce an image preview view
A preparation step for image preview UI update.
A new view, ImagePreviewView, that encapsulates image preview
grid UI is created. The view has a generalized interface for image
loading: it accepts a list of image URIs and a suspend function that
would perform actual image loading.
The image loading is still delegated to
ChooserContentPreviewCoordinator class that has the following collateral
changes:
- all UI logic is moved out of the class either into the new view or
into ChooserContentPreviewUI;
- mOnSingleImageSuccessCallback is removed and replaced with a
separate callback (see description below).
ChooserActivity used
ChooserContentPreviewCoordinator#mOnSingleImageSuccess as a signal to
start lisneting for the shared element transition animation (SETA) target
readiness. With all image preview UI logic now being encapsulated in the
new ImagePreviewView view, the new view triggers a SETA target readiness
callback instead, if configured. As ChooserActivity explicitely resumes
SETA for a non-image preview, ChooserContentPreviewCoordinator always
triggers image callback and the new view always notify about SETA target
readiness, we should be fine with remplacing the after-image-loaded
callback.
Flag: IntentResolver package entirely behind the CHOOSER_UNBUNDLED which is in teamfood
Bug: 262280076
Test: manual tests for all previe types, multi- and single profile and
SETA (share a shcreenshot) tests.
Test: delay image loading and test that in all cases SETA is not get
stuck.
Test: atest IntentResolverUnitTests
Change-Id: I081ab98c2bcb24cd2ad96b508ab559d7775aeaf4
9 files changed, 384 insertions, 195 deletions
@@ -51,6 +51,12 @@ android_library { "androidx.viewpager_viewpager", "androidx.lifecycle_lifecycle-common-java8", "androidx.lifecycle_lifecycle-extensions", + "androidx.lifecycle_lifecycle-runtime-ktx", + "androidx.lifecycle_lifecycle-viewmodel-ktx", + "kotlin-stdlib", + "kotlinx_coroutines", + "kotlinx-coroutines-android", + "//external/kotlinc:kotlin-annotations", "guava", ], diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 9d1dc208..5c324140 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -24,61 +24,14 @@ android:layout_height="wrap_content" android:orientation="vertical" android:background="?android:attr/colorBackground"> - <RelativeLayout + + <com.android.intentresolver.widget.ImagePreviewView android:id="@androidprv:id/content_preview_image_area" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:paddingBottom="@dimen/chooser_view_spacing" - android:background="?android:attr/colorBackground"> - - <com.android.intentresolver.widget.RoundedRectImageView - android:id="@androidprv:id/content_preview_image_1_large" - android:layout_width="120dp" - android:layout_height="104dp" - android:layout_alignParentTop="true" - android:adjustViewBounds="true" - android:gravity="center" - android:scaleType="centerCrop"/> - - <com.android.intentresolver.widget.RoundedRectImageView - android:id="@androidprv:id/content_preview_image_2_large" - android:visibility="gone" - android:layout_width="120dp" - android:layout_height="104dp" - android:layout_alignParentTop="true" - android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" - android:layout_marginLeft="10dp" - android:adjustViewBounds="true" - android:gravity="center" - android:scaleType="centerCrop"/> - - <com.android.intentresolver.widget.RoundedRectImageView - android:id="@androidprv:id/content_preview_image_2_small" - android:visibility="gone" - android:layout_width="120dp" - android:layout_height="65dp" - android:layout_alignParentTop="true" - android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" - android:layout_marginLeft="10dp" - android:adjustViewBounds="true" - android:gravity="center" - android:scaleType="centerCrop"/> - - <com.android.intentresolver.widget.RoundedRectImageView - android:id="@androidprv:id/content_preview_image_3_small" - android:visibility="gone" - android:layout_width="120dp" - android:layout_height="65dp" - android:layout_below="@androidprv:id/content_preview_image_2_small" - android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" - android:layout_marginLeft="10dp" - android:layout_marginTop="10dp" - android:adjustViewBounds="true" - android:gravity="center" - android:scaleType="centerCrop"/> - - </RelativeLayout> + android:background="?android:attr/colorBackground" /> <ViewStub android:id="@+id/action_row_stub" diff --git a/java/res/layout/image_preview_view.xml b/java/res/layout/image_preview_view.xml new file mode 100644 index 00000000..d2f94690 --- /dev/null +++ b/java/res/layout/image_preview_view.xml @@ -0,0 +1,72 @@ +<!-- + ~ Copyright (C) 2022 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. + --> + +<merge + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:paddingBottom="@dimen/chooser_view_spacing" + android:background="?android:attr/colorBackground"> + + <com.android.intentresolver.widget.RoundedRectImageView + android:id="@androidprv:id/content_preview_image_1_large" + android:layout_width="120dp" + android:layout_height="104dp" + android:layout_alignParentTop="true" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + + <com.android.intentresolver.widget.RoundedRectImageView + android:id="@androidprv:id/content_preview_image_2_large" + android:visibility="gone" + android:layout_width="120dp" + android:layout_height="104dp" + android:layout_alignParentTop="true" + android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" + android:layout_marginLeft="10dp" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + + <com.android.intentresolver.widget.RoundedRectImageView + android:id="@androidprv:id/content_preview_image_2_small" + android:visibility="gone" + android:layout_width="120dp" + android:layout_height="65dp" + android:layout_alignParentTop="true" + android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" + android:layout_marginLeft="10dp" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + + <com.android.intentresolver.widget.RoundedRectImageView + android:id="@androidprv:id/content_preview_image_3_small" + android:visibility="gone" + android:layout_width="120dp" + android:layout_height="65dp" + android:layout_below="@androidprv:id/content_preview_image_2_small" + android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" + android:layout_marginLeft="10dp" + android:layout_marginTop="10dp" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + +</merge> diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 83cdc9ef..df71c7ff 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -259,23 +259,17 @@ public class ChooserActivity extends ResolverActivity implements public ChooserActivity() {} - private void setupPreDrawForSharedElementTransition(View v) { - v.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { - @Override - public boolean onPreDraw() { - v.getViewTreeObserver().removeOnPreDrawListener(this); - - if (!mRemoveSharedElements && isActivityTransitionRunning()) { - // Disable the window animations as it interferes with the transition animation. - getWindow().setWindowAnimations(0); - } - mEnterTransitionAnimationDelegate.markImagePreviewReady(); - return true; - } - }); + private void onSharedElementTransitionTargetReady(boolean runTransitionAnimation) { + if (runTransitionAnimation && !mRemoveSharedElements && isActivityTransitionRunning()) { + // Disable the window animations as it interferes with the transition animation. + getWindow().setWindowAnimations(0); + mEnterTransitionAnimationDelegate.markImagePreviewReady(); + } else { + onSharedElementTransitionTargetMissing(); + } } - private void hideContentPreview() { + private void onSharedElementTransitionTargetMissing() { mRemoveSharedElements = true; mEnterTransitionAnimationDelegate.markImagePreviewReady(); } @@ -318,8 +312,7 @@ public class ChooserActivity extends ResolverActivity implements mPreviewCoordinator = new ChooserContentPreviewCoordinator( mBackgroundThreadPoolExecutor, this, - this::hideContentPreview, - this::setupPreDrawForSharedElementTransition); + this::onSharedElementTransitionTargetMissing); super.onCreate( savedInstanceState, @@ -779,6 +772,7 @@ public class ChooserActivity extends ResolverActivity implements R.layout.chooser_action_row, parent, previewCoordinator, + this::onSharedElementTransitionTargetReady, getContentResolver(), this::isImageType); diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java index fdc58170..93552e31 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java @@ -16,28 +16,20 @@ package com.android.intentresolver; -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; -import android.annotation.NonNull; import android.graphics.Bitmap; import android.net.Uri; import android.os.Handler; import android.util.Size; -import android.view.View; -import android.view.animation.DecelerateInterpolator; import androidx.annotation.MainThread; import androidx.annotation.Nullable; -import com.android.intentresolver.widget.RoundedRectImageView; - import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; -import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.function.Consumer; @@ -50,25 +42,23 @@ public class ChooserContentPreviewCoordinator implements public ChooserContentPreviewCoordinator( ExecutorService backgroundExecutor, ChooserActivity chooserActivity, - Runnable onFailCallback, - Consumer<View> onSingleImageSuccessCallback) { + Runnable onFailCallback) { this.mBackgroundExecutor = MoreExecutors.listeningDecorator(backgroundExecutor); this.mChooserActivity = chooserActivity; this.mOnFailCallback = onFailCallback; - this.mOnSingleImageSuccessCallback = onSingleImageSuccessCallback; this.mImageLoadTimeoutMillis = chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime); } @Override - public void loadUriIntoView( - final Callable<RoundedRectImageView> deferredImageViewProvider, - final Uri imageUri, - final int extraImageCount) { + public void loadImage(final Uri imageUri, final Consumer<Bitmap> callback) { final int size = mChooserActivity.getResources().getDimensionPixelSize( R.dimen.chooser_preview_image_max_dimen); + // TODO: apparently this timeout is only used for not holding shared element transition + // animation for too long. If so, we already have a better place for it + // ChooserActivity$EnterTransitionAnimationDelegate. mHandler.postDelayed(this::onWatchdogTimeout, mImageLoadTimeoutMillis); ListenableFuture<Bitmap> bitmapFuture = mBackgroundExecutor.submit( @@ -80,25 +70,22 @@ public class ChooserContentPreviewCoordinator implements @Override public void onSuccess(Bitmap loadedBitmap) { try { - onLoadCompleted( - deferredImageViewProvider.call(), - loadedBitmap, - extraImageCount); + callback.accept(loadedBitmap); + onLoadCompleted(loadedBitmap); } catch (Exception e) { /* unimportant */ } } @Override - public void onFailure(Throwable t) {} + public void onFailure(Throwable t) { + callback.accept(null); + } }, mHandler::post); } - private static final int IMAGE_FADE_IN_MILLIS = 150; - private final ChooserActivity mChooserActivity; private final ListeningExecutorService mBackgroundExecutor; private final Runnable mOnFailCallback; - private final Consumer<View> mOnSingleImageSuccessCallback; private final int mImageLoadTimeoutMillis; // TODO: this uses a `Handler` because there doesn't seem to be a straightforward way to get a @@ -121,60 +108,25 @@ public class ChooserContentPreviewCoordinator implements } @MainThread - private void onLoadCompleted( - @Nullable RoundedRectImageView imageView, - @Nullable Bitmap loadedBitmap, - int extraImageCount) { + private void onLoadCompleted(@Nullable Bitmap loadedBitmap) { if (mChooserActivity.isFinishing()) { return; } - // TODO: legacy logic didn't handle a possible null view; handle the same as other - // single-image failures for now (i.e., this is also a factor in the "race" TODO below). - boolean thisLoadSucceeded = (imageView != null) && (loadedBitmap != null); - mAtLeastOneLoaded |= thisLoadSucceeded; - - // TODO: this looks like a race condition. We may know that this specific image failed (i.e. - // it got a null Bitmap), but we'll only report that to the client (thereby failing out our - // pending loads) if we haven't yet succeeded in loading some other non-null Bitmap. But - // there could be other pending loads that would've returned non-null within the timeout - // window, except they end up (effectively) cancelled because this one single-image load - // "finished" (failed) faster. The outcome of that race may be fairly predictable (since we - // *might* imagine that the nulls would usually "load" faster?), but it's not guaranteed - // since the loads are queued in a thread pool (i.e., in parallel). One option for more - // deterministic behavior: don't signal the failure callback on a single-image load unless - // there are no other loads currently pending. + // TODO: the following logic can be described as "invoke the fail callback when the first + // image loading has failed". Historically, before we had switched from a single-threaded + // pool to a multi-threaded pool, we first loaded the transition element's image (the image + // preview is the only case when those callbacks matter) and aborting the animation on it's + // failure was reasonable. With the multi-thread pool, the first result may belong to any + // image and thus we can falsely abort the animation. + // Now, when we track the transition view state directly and after the timeout logic will + // be moved into ChooserActivity$EnterTransitionAnimationDelegate, we can just get rid of + // the fail callback and the following logic altogether. + mAtLeastOneLoaded |= loadedBitmap != null; boolean wholeBatchFailed = !mAtLeastOneLoaded; - if (thisLoadSucceeded) { - onImageLoadedSuccessfully(loadedBitmap, imageView, extraImageCount); - } else if (imageView != null) { - imageView.setVisibility(View.GONE); - } - if (wholeBatchFailed) { mOnFailCallback.run(); } } - - @MainThread - private void onImageLoadedSuccessfully( - @NonNull Bitmap image, - RoundedRectImageView imageView, - int extraImageCount) { - imageView.setVisibility(View.VISIBLE); - imageView.setAlpha(0.0f); - imageView.setImageBitmap(image); - - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); - fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); - fadeAnim.start(); - - if (extraImageCount > 0) { - imageView.setExtraImageCount(extraImageCount); - } - - mOnSingleImageSuccessCallback.accept(imageView); - } } diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java index 0cadce4b..ff88e5e1 100644 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java @@ -18,12 +18,15 @@ package com.android.intentresolver; import static java.lang.annotation.RetentionPolicy.SOURCE; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; import android.annotation.IntDef; import android.content.ClipData; import android.content.ContentResolver; import android.content.Intent; import android.content.res.Resources; import android.database.Cursor; +import android.graphics.Bitmap; import android.net.Uri; import android.provider.DocumentsContract; import android.provider.Downloads; @@ -35,6 +38,7 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewStub; +import android.view.animation.DecelerateInterpolator; import android.widget.ImageView; import android.widget.TextView; @@ -42,6 +46,7 @@ import androidx.annotation.LayoutRes; import androidx.annotation.Nullable; import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.RoundedRectImageView; import com.android.internal.annotations.VisibleForTesting; @@ -50,7 +55,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.Callable; +import java.util.function.Consumer; /** * Collection of helpers for building the content preview UI displayed in {@link ChooserActivity}. @@ -64,6 +69,8 @@ import java.util.concurrent.Callable; * as ivars when this "class" is initialized. */ public final class ChooserContentPreviewUi { + private static final int IMAGE_FADE_IN_MILLIS = 150; + /** * Delegate to handle background resource loads that are dependencies of content previews. */ @@ -71,19 +78,12 @@ public final class ChooserContentPreviewUi { /** * Request that an image be loaded in the background and set into a view. * - * @param viewProvider A delegate that will be called exactly once upon completion of the - * load, from the UI thread, to provide the {@link RoundedRectImageView} that should be - * populated with the result (if the load was successful) or hidden (if the load failed). If - * this returns null, the load is discarded as a failure. * @param imageUri The {@link Uri} of the image to load. - * @param extraImages The "extra image count" to set on the {@link RoundedRectImageView} - * if the image loads successfully. * * TODO: it looks like clients are probably capable of passing the view directly, but the * deferred computation here is a closer match to the legacy model for now. */ - void loadUriIntoView( - Callable<RoundedRectImageView> viewProvider, Uri imageUri, int extraImages); + void loadImage(Uri imageUri, Consumer<Bitmap> callback); } /** @@ -182,6 +182,7 @@ public final class ChooserContentPreviewUi { @LayoutRes int actionRowLayout, ViewGroup parent, ContentPreviewCoordinator previewCoord, + Consumer<Boolean> onTransitionTargetReady, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier) { ViewGroup layout = null; @@ -203,6 +204,7 @@ public final class ChooserContentPreviewUi { createImagePreviewActions(actionFactory), parent, previewCoord, + onTransitionTargetReady, contentResolver, imageClassifier, actionRowLayout); @@ -290,11 +292,12 @@ public final class ChooserContentPreviewUi { if (previewThumbnail == null) { previewThumbnailView.setVisibility(View.GONE); } else { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_thumbnail), + previewCoord.loadImage( previewThumbnail, - 0); + (bitmap) -> updateViewWithImage( + contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail), + bitmap)); } } @@ -317,12 +320,13 @@ public final class ChooserContentPreviewUi { List<ActionRow.Action> actions, ViewGroup parent, ContentPreviewCoordinator previewCoord, + Consumer<Boolean> onTransitionTargetReady, ContentResolver contentResolver, ImageMimeTypeClassifier imageClassifier, @LayoutRes int actionRowLayout) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); - ViewGroup imagePreview = contentPreviewLayout.findViewById( + ImagePreviewView imagePreview = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_image_area); final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); @@ -330,60 +334,35 @@ public final class ChooserContentPreviewUi { actionRow.setActions(actions); } + final ImagePreviewImageLoader imageLoader = new ImagePreviewImageLoader(previewCoord); + final ArrayList<Uri> imageUris = new ArrayList<>(); String action = targetIntent.getAction(); if (Intent.ACTION_SEND.equals(action)) { + // TODO: why don't we use image classifier in this case as well? Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) - .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_1_large), - uri, - 0); + imageUris.add(uri); } else { List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - List<Uri> imageUris = new ArrayList<>(); for (Uri uri : uris) { if (imageClassifier.isImageType(contentResolver.getType(uri))) { imageUris.add(uri); } } + } - if (imageUris.size() == 0) { - Log.i(TAG, "Attempted to display image preview area with zero" - + " available images detected in EXTRA_STREAM list"); - imagePreview.setVisibility(View.GONE); - return contentPreviewLayout; - } - - imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) - .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_1_large), - imageUris.get(0), - 0); - - if (imageUris.size() == 2) { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_2_large), - imageUris.get(1), - 0); - } else if (imageUris.size() > 2) { - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_2_small), - imageUris.get(1), - 0); - previewCoord.loadUriIntoView( - () -> contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_3_small), - imageUris.get(2), - imageUris.size() - 3); - } + if (imageUris.size() == 0) { + Log.i(TAG, "Attempted to display image preview area with zero" + + " available images detected in EXTRA_STREAM list"); + imagePreview.setVisibility(View.GONE); + onTransitionTargetReady.accept(false); + return contentPreviewLayout; } + imagePreview.setSharedElementTransitionTarget( + ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME, + onTransitionTargetReady); + imagePreview.setImages(imageUris, imageLoader); + return contentPreviewLayout; } @@ -503,11 +482,12 @@ public final class ChooserContentPreviewUi { fileNameView.setText(fileInfo.name); if (fileInfo.hasThumbnail) { - previewCoord.loadUriIntoView( - () -> parent.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail), + previewCoord.loadImage( uri, - 0); + (bitmap) -> updateViewWithImage( + parent.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail), + bitmap)); } else { View thumbnailView = parent.findViewById( com.android.internal.R.id.content_preview_file_thumbnail); @@ -520,6 +500,21 @@ public final class ChooserContentPreviewUi { } } + private static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) { + if (image == null) { + imageView.setVisibility(View.GONE); + return; + } + imageView.setVisibility(View.VISIBLE); + imageView.setAlpha(0.0f); + imageView.setImageBitmap(image); + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); + fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); + fadeAnim.start(); + } + private static FileInfo extractFileInfo(Uri uri, ContentResolver resolver) { String fileName = null; boolean hasThumbnail = false; diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt new file mode 100644 index 00000000..e68eb66a --- /dev/null +++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2022 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.intentresolver + +import android.graphics.Bitmap +import android.net.Uri +import kotlinx.coroutines.suspendCancellableCoroutine + +// TODO: convert ChooserContentPreviewCoordinator to Kotlin and merge this class into it. +internal class ImagePreviewImageLoader( + private val previewCoordinator: ChooserContentPreviewUi.ContentPreviewCoordinator +) : suspend (Uri) -> Bitmap? { + + override suspend fun invoke(uri: Uri): Bitmap? = + suspendCancellableCoroutine { continuation -> + val callback = java.util.function.Consumer<Bitmap?> { bitmap -> + try { + continuation.resumeWith(Result.success(bitmap)) + } catch (ignored: Exception) { + } + } + previewCoordinator.loadImage(uri, callback) + } +} diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt new file mode 100644 index 00000000..a37ef954 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2022 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.intentresolver.widget + +import android.animation.ObjectAnimator +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewTreeObserver +import android.view.animation.DecelerateInterpolator +import android.widget.RelativeLayout +import androidx.core.view.isVisible +import com.android.intentresolver.R +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import java.util.function.Consumer +import com.android.internal.R as IntR + +typealias ImageLoader = suspend (Uri) -> Bitmap? + +private const val IMAGE_FADE_IN_MILLIS = 150L + +class ImagePreviewView : RelativeLayout { + + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int + ) : this(context, attrs, defStyleAttr, 0) + + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + private val coroutineScope = MainScope() + private lateinit var mainImage: RoundedRectImageView + private lateinit var secondLargeImage: RoundedRectImageView + private lateinit var secondSmallImage: RoundedRectImageView + private lateinit var thirdImage: RoundedRectImageView + + private var loadImageJob: Job? = null + private var onTransitionViewReadyCallback: Consumer<Boolean>? = null + + override fun onFinishInflate() { + LayoutInflater.from(context).inflate(R.layout.image_preview_view, this, true) + mainImage = requireViewById(IntR.id.content_preview_image_1_large) + secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large) + secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small) + thirdImage = requireViewById(IntR.id.content_preview_image_3_small) + } + + /** + * Specifies a transition animation target name and a readiness callback. The callback will be + * invoked once when the view preparation is done i.e. either when an image is loaded into it + * and it is laid out (and it is ready to be draw) or image loading has failed. + * Should be called before [setImages]. + * @param name, transition name + * @param onViewReady, a callback that will be invoked with `true` if the view is ready to + * receive transition animation (the image was loaded successfully) and with `false` otherwise. + */ + fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer<Boolean>) { + mainImage.transitionName = name + onTransitionViewReadyCallback = onViewReady + } + + fun setImages(uris: List<Uri>, imageLoader: ImageLoader) { + loadImageJob?.cancel() + loadImageJob = coroutineScope.launch { + when (uris.size) { + 0 -> hideAllViews() + 1 -> showOneImage(uris, imageLoader) + 2 -> showTwoImages(uris, imageLoader) + else -> showThreeImages(uris, imageLoader) + } + } + } + + private fun hideAllViews() { + mainImage.isVisible = false + secondLargeImage.isVisible = false + secondSmallImage.isVisible = false + thirdImage.isVisible = false + invokeTransitionViewReadyCallback(runTransitionAnimation = false) + } + + private suspend fun showOneImage(uris: List<Uri>, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage) + } + + private suspend fun showTwoImages(uris: List<Uri>, imageLoader: ImageLoader) { + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondLargeImage) + } + + private suspend fun showThreeImages(uris: List<Uri>, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage) + thirdImage.setExtraImageCount(uris.size - 3) + } + + private suspend fun showImages( + uris: List<Uri>, imageLoader: ImageLoader, vararg views: RoundedRectImageView + ) = coroutineScope { + for (i in views.indices) { + launch { + loadImageIntoView(views[i], uris[i], imageLoader) + } + } + } + + private suspend fun loadImageIntoView( + view: RoundedRectImageView, uri: Uri, imageLoader: ImageLoader + ) { + val bitmap = runCatching { + imageLoader(uri) + }.getOrDefault(null) + if (bitmap == null) { + view.isVisible = false + if (view === mainImage) { + invokeTransitionViewReadyCallback(runTransitionAnimation = false) + } + } else { + view.isVisible = true + view.setImageBitmap(bitmap) + + view.alpha = 0f + ObjectAnimator.ofFloat(view, "alpha", 0.0f, 1.0f).apply { + interpolator = DecelerateInterpolator(1.0f) + duration = IMAGE_FADE_IN_MILLIS + start() + } + if (view === mainImage && onTransitionViewReadyCallback != null) { + setupPreDrawListener(mainImage) + } + } + } + + private fun setupPreDrawListener(view: View) { + view.viewTreeObserver.addOnPreDrawListener( + object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + view.viewTreeObserver.removeOnPreDrawListener(this) + invokeTransitionViewReadyCallback(runTransitionAnimation = true) + return true + } + } + ) + } + + private fun invokeTransitionViewReadyCallback(runTransitionAnimation: Boolean) { + onTransitionViewReadyCallback?.accept(runTransitionAnimation) + onTransitionViewReadyCallback = null + } +} diff --git a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java index cf7bd543..8538041b 100644 --- a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java +++ b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java @@ -96,6 +96,7 @@ public class RoundedRectImageView extends ImageView { } else { this.mExtraImageCount = null; } + invalidate(); } @Override |