Simple scrollable image preview view
A simple scrollale image preview view is created but not yet used.
Bug: 262280076
Test: Add a debug code that replaces legacy image preview view with the
new implementation. Verify basic functionality: small and large image
count, screenshot transition animation.
Change-Id: I5d8f00b8617abae66f76931b21872154f4726851
diff --git a/java/res/layout/image_preview_image_item.xml b/java/res/layout/image_preview_image_item.xml
new file mode 100644
index 0000000..3895b6b
--- /dev/null
+++ b/java/res/layout/image_preview_image_item.xml
@@ -0,0 +1,24 @@
+<!--
+ ~ Copyright (C) 2023 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.
+ -->
+
+<com.android.intentresolver.widget.RoundedRectImageView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/image"
+ android:layout_width="120dp"
+ android:layout_height="104dp"
+ android:layout_alignParentTop="true"
+ android:adjustViewBounds="false"
+ android:scaleType="centerCrop"/>
diff --git a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt
new file mode 100644
index 0000000..a790600
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2023 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.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+internal val RecyclerView.areAllChildrenVisible: Boolean
+ get() {
+ val count = getChildCount()
+ if (count == 0) return true
+ val first = getChildAt(0)
+ val last = getChildAt(count - 1)
+ val itemCount = adapter?.itemCount ?: 0
+ return getChildAdapterPosition(first) == 0
+ && getChildAdapterPosition(last) == itemCount - 1
+ && isFullyVisible(first)
+ && isFullyVisible(last)
+ }
+
+private fun RecyclerView.isFullyVisible(view: View): Boolean =
+ view.left >= paddingLeft && view.right <= width - paddingRight
diff --git a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt
index a941b97..8163054 100644
--- a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt
+++ b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt
@@ -50,21 +50,6 @@
)
}
- private val areAllChildrenVisible: Boolean
- get() {
- val count = getChildCount()
- if (count == 0) return true
- val first = getChildAt(0)
- val last = getChildAt(count - 1)
- return getChildAdapterPosition(first) == 0
- && getChildAdapterPosition(last) == actionsAdapter.itemCount - 1
- && isFullyVisible(first)
- && isFullyVisible(last)
- }
-
- private fun isFullyVisible(view: View): Boolean =
- view.left >= paddingLeft && view.right <= width - paddingRight
-
private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() {
private val iconSize: Int =
context.resources.getDimensionPixelSize(R.dimen.chooser_action_view_icon_size)
diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
new file mode 100644
index 0000000..467c404
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2023 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.content.Context
+import android.graphics.Rect
+import android.net.Uri
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.intentresolver.R
+import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+
+private const val TRANSITION_NAME = "screenshot_preview_image"
+
+class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
+ constructor(context: Context) : this(context, null)
+ constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
+ constructor(
+ context: Context, attrs: AttributeSet?, defStyleAttr: Int
+ ) : super(context, attrs, defStyleAttr) {
+ layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
+ adapter = Adapter(context)
+ val spacing = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, 5f, context.resources.displayMetrics
+ ).toInt()
+ addItemDecoration(SpacingDecoration(spacing))
+ }
+
+ private val previewAdapter get() = adapter as Adapter
+
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
+ super.onLayout(changed, l, t, r, b)
+ setOverScrollMode(
+ if (areAllChildrenVisible) View.OVER_SCROLL_NEVER else View.OVER_SCROLL_ALWAYS
+ )
+ }
+
+ override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) {
+ previewAdapter.transitionStatusElementCallback = callback
+ }
+
+ override fun setImages(uris: List<Uri>, imageLoader: ImageLoader) {
+ previewAdapter.setImages(uris, imageLoader)
+ }
+
+ private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() {
+ private val uris = ArrayList<Uri>()
+ private var imageLoader: ImageLoader? = null
+ var transitionStatusElementCallback: TransitionElementStatusCallback? = null
+
+ fun setImages(uris: List<Uri>, imageLoader: ImageLoader) {
+ this.uris.clear()
+ this.uris.addAll(uris)
+ this.imageLoader = imageLoader
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder {
+ return ViewHolder(
+ LayoutInflater.from(context)
+ .inflate(R.layout.image_preview_image_item, parent, false)
+ )
+ }
+
+ override fun getItemCount(): Int = uris.size
+
+ override fun onBindViewHolder(vh: ViewHolder, position: Int) {
+ vh.bind(
+ uris[position],
+ imageLoader ?: error("ImageLoader is missing"),
+ if (position == 0 && transitionStatusElementCallback != null) {
+ this::onTransitionElementReady
+ } else {
+ null
+ }
+ )
+ }
+
+ override fun onViewRecycled(vh: ViewHolder) {
+ vh.unbind()
+ }
+
+ override fun onFailedToRecycleView(vh: ViewHolder): Boolean {
+ vh.unbind()
+ return super.onFailedToRecycleView(vh)
+ }
+
+ private fun onTransitionElementReady(name: String) {
+ transitionStatusElementCallback?.apply {
+ onTransitionElementReady(name)
+ onAllTransitionElementsReady()
+ }
+ transitionStatusElementCallback = null
+ }
+ }
+
+ private class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ private val image = view.requireViewById<ImageView>(R.id.image)
+ private var scope: CoroutineScope? = null
+
+ fun bind(
+ uri: Uri,
+ imageLoader: ImageLoader,
+ previewReadyCallback: ((String) -> Unit)?
+ ) {
+ image.setImageDrawable(null)
+ image.transitionName = if (previewReadyCallback != null) {
+ TRANSITION_NAME
+ } else {
+ null
+ }
+ resetScope().launch {
+ loadImage(uri, imageLoader, previewReadyCallback)
+ }
+ }
+
+ private suspend fun loadImage(
+ uri: Uri,
+ imageLoader: ImageLoader,
+ previewReadyCallback: ((String) -> Unit)?
+ ) {
+ val bitmap = runCatching {
+ // it's expected for all loading/caching optimizations to be implemented by the
+ // loader
+ imageLoader(uri)
+ }.getOrNull()
+ image.setImageBitmap(bitmap)
+ previewReadyCallback?.let { callback ->
+ image.waitForPreDraw()
+ callback(TRANSITION_NAME)
+ }
+ }
+
+ private fun resetScope(): CoroutineScope =
+ (MainScope() + Dispatchers.Main.immediate).also {
+ scope?.cancel()
+ scope = it
+ }
+
+ fun unbind() {
+ scope?.cancel()
+ scope = null
+ }
+ }
+
+ private class SpacingDecoration(private val margin: Int) : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) {
+ outRect.set(margin, 0, margin, 0)
+ }
+ }
+}