From 53d426f78b1702ae471ae57fc6818674f196954c Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 17 Jan 2023 20:00:17 -0800 Subject: 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 --- java/res/layout/image_preview_image_item.xml | 24 +++ .../widget/RecyclerViewExtensions.kt | 36 +++++ .../intentresolver/widget/ScrollableActionRow.kt | 15 -- .../widget/ScrollableImagePreviewView.kt | 178 +++++++++++++++++++++ 4 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 java/res/layout/image_preview_image_item.xml create mode 100644 java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt create mode 100644 java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt (limited to 'java') 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 00000000..3895b6b4 --- /dev/null +++ b/java/res/layout/image_preview_image_item.xml @@ -0,0 +1,24 @@ + + + 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 00000000..a7906001 --- /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 a941b97a..81630545 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt @@ -50,21 +50,6 @@ class ScrollableActionRow : RecyclerView, ActionRow { ) } - 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() { 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 00000000..467c404a --- /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, imageLoader: ImageLoader) { + previewAdapter.setImages(uris, imageLoader) + } + + private class Adapter(private val context: Context) : RecyclerView.Adapter() { + private val uris = ArrayList() + private var imageLoader: ImageLoader? = null + var transitionStatusElementCallback: TransitionElementStatusCallback? = null + + fun setImages(uris: List, 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(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) + } + } +} -- cgit v1.2.3-59-g8ed1b