Merge pull request #10174 from nextcloud/new-fastscroll-lib

Fast scrolling fixes
This commit is contained in:
Álvaro Brey 2022-05-06 12:08:06 +02:00 committed by GitHub
commit db85ebba94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 347 additions and 33 deletions

View file

@ -272,7 +272,7 @@ dependencies {
implementation "com.google.android.exoplayer:exoplayer:$exoplayerVersion" implementation "com.google.android.exoplayer:exoplayer:$exoplayerVersion"
implementation "com.google.android.exoplayer:extension-okhttp:$exoplayerVersion" implementation "com.google.android.exoplayer:extension-okhttp:$exoplayerVersion"
implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1' implementation 'me.zhanghai.android.fastscroll:library:1.1.8'
// Shimmer animation // Shimmer animation
implementation 'io.github.elye:loaderviewlibrary:3.0.0' implementation 'io.github.elye:loaderviewlibrary:3.0.0'

View file

@ -0,0 +1,61 @@
/*
* Nextcloud Android Library is available under MIT license
*
* @author Álvaro Brey Vilas
* Copyright (C) 2022 Álvaro Brey Vilas
* Copyright (C) 2022 Nextcloud GmbH
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
* BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
* ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package com.nextcloud.utils.view
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
import me.zhanghai.android.fastscroll.FastScroller
import me.zhanghai.android.fastscroll.FastScrollerBuilder
object FastScroll {
@JvmStatic
@JvmOverloads
fun applyFastScroll(recyclerView: RecyclerView, viewHelper: FastScroller.ViewHelper? = null) {
val builder = FastScrollerBuilder(recyclerView).useMd2Style()
if (viewHelper != null) {
builder.setViewHelper(viewHelper)
}
builder.build()
}
@JvmStatic
fun fixAppBarForFastScroll(appBarLayout: AppBarLayout, content: ViewGroup) {
val contentLayoutInitialPaddingBottom = content.paddingBottom
appBarLayout.addOnOffsetChangedListener(
AppBarLayout.OnOffsetChangedListener { _, offset ->
content.setPadding(
content.paddingLeft,
content.paddingTop,
content.paddingRight,
contentLayoutInitialPaddingBottom + appBarLayout.totalScrollRange + offset
)
}
)
}
}

View file

@ -25,14 +25,13 @@ import android.content.Context;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.View; import android.view.View;
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
/** /**
* Extends RecyclerView to show a custom view if no data is available Inspired by http://alexzh.com/tutorials/how-to-setemptyview-to-recyclerview * Extends RecyclerView to show a custom view if no data is available Inspired by http://alexzh.com/tutorials/how-to-setemptyview-to-recyclerview
*/ */
public class EmptyRecyclerView extends FastScrollRecyclerView { public class EmptyRecyclerView extends RecyclerView {
private View mEmptyView; private View mEmptyView;
private boolean hasFooter = false; private boolean hasFooter = false;

View file

@ -61,6 +61,7 @@ import com.nextcloud.client.network.ConnectivityService;
import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.client.preferences.AppPreferences;
import com.nextcloud.client.utils.IntentUtil; import com.nextcloud.client.utils.IntentUtil;
import com.nextcloud.java.util.Optional; import com.nextcloud.java.util.Optional;
import com.nextcloud.utils.view.FastScroll;
import com.owncloud.android.MainApp; import com.owncloud.android.MainApp;
import com.owncloud.android.R; import com.owncloud.android.R;
import com.owncloud.android.databinding.FilesBinding; import com.owncloud.android.databinding.FilesBinding;
@ -137,7 +138,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting; import androidx.annotation.VisibleForTesting;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SearchView; import androidx.appcompat.widget.SearchView;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.view.MenuItemCompat; import androidx.core.view.MenuItemCompat;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
@ -266,6 +266,9 @@ public class FileDisplayActivity extends FileActivity
mSwitchAccountButton.setOnClickListener(v -> showManageAccountsDialog()); mSwitchAccountButton.setOnClickListener(v -> showManageAccountsDialog());
FastScroll.fixAppBarForFastScroll(binding.appbar.appbar, binding.rootLayout);
// Init Fragment without UI to retain AsyncTask across configuration changes // Init Fragment without UI to retain AsyncTask across configuration changes
FragmentManager fm = getSupportFragmentManager(); FragmentManager fm = getSupportFragmentManager();
TaskRetainerFragment taskRetainerFragment = TaskRetainerFragment taskRetainerFragment =

View file

@ -45,7 +45,7 @@ import com.owncloud.android.utils.FileSortOrder
import com.owncloud.android.utils.FileStorageUtils import com.owncloud.android.utils.FileStorageUtils
import com.owncloud.android.utils.theme.ThemeColorUtils import com.owncloud.android.utils.theme.ThemeColorUtils
import com.owncloud.android.utils.theme.ThemeDrawableUtils import com.owncloud.android.utils.theme.ThemeDrawableUtils
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter import me.zhanghai.android.fastscroll.PopupTextProvider
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
@ -58,8 +58,8 @@ class GalleryAdapter(
transferServiceGetter: ComponentsGetter, transferServiceGetter: ComponentsGetter,
themeColorUtils: ThemeColorUtils, themeColorUtils: ThemeColorUtils,
themeDrawableUtils: ThemeDrawableUtils themeDrawableUtils: ThemeDrawableUtils
) : SectionedRecyclerViewAdapter<SectionedViewHolder>(), CommonOCFileListAdapterInterface, SectionedAdapter { ) : SectionedRecyclerViewAdapter<SectionedViewHolder>(), CommonOCFileListAdapterInterface, PopupTextProvider {
private var files: List<GalleryItems> = mutableListOf() var files: List<GalleryItems> = mutableListOf()
private val ocFileListDelegate: OCFileListDelegate private val ocFileListDelegate: OCFileListDelegate
private var storageManager: FileDataStorageManager private var storageManager: FileDataStorageManager
@ -122,7 +122,7 @@ class GalleryAdapter(
return files.size return files.size
} }
override fun getSectionName(position: Int): String { override fun getPopupText(position: Int): String {
return DisplayUtils.getDateByPattern( return DisplayUtils.getDateByPattern(
files[getRelativePosition(position).section()].date, files[getRelativePosition(position).section()].date,
context, context,

View file

@ -74,7 +74,6 @@ import com.owncloud.android.utils.theme.CapabilityUtils;
import com.owncloud.android.utils.theme.ThemeAvatarUtils; import com.owncloud.android.utils.theme.ThemeAvatarUtils;
import com.owncloud.android.utils.theme.ThemeColorUtils; import com.owncloud.android.utils.theme.ThemeColorUtils;
import com.owncloud.android.utils.theme.ThemeDrawableUtils; import com.owncloud.android.utils.theme.ThemeDrawableUtils;
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView;
import java.io.File; import java.io.File;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
@ -90,14 +89,14 @@ import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting; import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import me.zhanghai.android.fastscroll.PopupTextProvider;
/** /**
* This Adapter populates a RecyclerView with all files and folders in a Nextcloud instance. * This Adapter populates a RecyclerView with all files and folders in a Nextcloud instance.
*/ */
public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
implements DisplayUtils.AvatarGenerationListener, implements DisplayUtils.AvatarGenerationListener,
CommonOCFileListAdapterInterface, CommonOCFileListAdapterInterface, PopupTextProvider {
FastScrollRecyclerView.SectionedAdapter {
private static final int showFilenameColumnThreshold = 4; private static final int showFilenameColumnThreshold = 4;
private final String userId; private final String userId;
@ -909,7 +908,7 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
@NonNull @NonNull
@Override @Override
public String getSectionName(int position) { public String getPopupText(int position) {
OCFile file = getItem(position); OCFile file = getItem(position);
if (file == null) { if (file == null) {

View file

@ -27,6 +27,7 @@ import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import com.nextcloud.utils.view.FastScroll;
import com.owncloud.android.R; import com.owncloud.android.R;
import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.datamodel.OCFile;
import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.lib.common.utils.Log_OC;
@ -35,6 +36,7 @@ import com.owncloud.android.ui.adapter.CommonOCFileListAdapterInterface;
import com.owncloud.android.ui.adapter.GalleryAdapter; import com.owncloud.android.ui.adapter.GalleryAdapter;
import com.owncloud.android.ui.asynctasks.GallerySearchTask; import com.owncloud.android.ui.asynctasks.GallerySearchTask;
import com.owncloud.android.ui.events.ChangeMenuEvent; import com.owncloud.android.ui.events.ChangeMenuEvent;
import com.owncloud.android.ui.fragment.util.GalleryFastScrollViewHelper;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentActivity;
@ -109,26 +111,14 @@ public class GalleryFragment extends OCFileListFragment {
themeColorUtils, themeColorUtils,
themeDrawableUtils); themeDrawableUtils);
// val spacing = resources.getDimensionPixelSize(R.dimen.media_grid_spacing)
// binding.list.addItemDecoration(MediaGridItemDecoration(spacing))
setRecyclerViewAdapter(mAdapter); setRecyclerViewAdapter(mAdapter);
GridLayoutManager layoutManager = new GridLayoutManager(getContext(), getColumnsCount()); GridLayoutManager layoutManager = new GridLayoutManager(getContext(), getColumnsCount());
// ((GridLayoutManager) layoutManager).setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
// @Override
// public int getSpanSize(int position) {
// if (position == getAdapter().getItemCount() - 1 ||
// position == 0 && getAdapter().shouldShowHeader()) {
// return ((GridLayoutManager) layoutManager).getSpanCount();
// } else {
// return 1;
// }
// }
// });
mAdapter.setLayoutManager(layoutManager); mAdapter.setLayoutManager(layoutManager);
getRecyclerView().setLayoutManager(layoutManager); getRecyclerView().setLayoutManager(layoutManager);
FastScroll.applyFastScroll(getRecyclerView(), new GalleryFastScrollViewHelper(getRecyclerView(), mAdapter));
} }
@Override @Override

View file

@ -59,6 +59,7 @@ import com.nextcloud.client.network.ClientFactory;
import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.client.preferences.AppPreferences;
import com.nextcloud.client.utils.Throttler; import com.nextcloud.client.utils.Throttler;
import com.nextcloud.common.NextcloudClient; import com.nextcloud.common.NextcloudClient;
import com.nextcloud.utils.view.FastScroll;
import com.owncloud.android.MainApp; import com.owncloud.android.MainApp;
import com.owncloud.android.R; import com.owncloud.android.R;
import com.owncloud.android.datamodel.ArbitraryDataProvider; import com.owncloud.android.datamodel.ArbitraryDataProvider;
@ -429,6 +430,8 @@ public class OCFileListFragment extends ExtendedListFragment implements
); );
setRecyclerViewAdapter(mAdapter); setRecyclerViewAdapter(mAdapter);
FastScroll.applyFastScroll(getRecyclerView());
} }
protected void prepareCurrentSearch(SearchEvent event) { protected void prepareCurrentSearch(SearchEvent event) {

View file

@ -0,0 +1,264 @@
/*
* Nextcloud Android Library is available under MIT license
*
* @author Álvaro Brey Vilas
* Copyright (C) 2022 Álvaro Brey Vilas
* Copyright (C) 2022 Nextcloud GmbH
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
* BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
* ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package com.owncloud.android.ui.fragment.util
import android.graphics.Canvas
import android.graphics.Rect
import android.view.MotionEvent
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
import androidx.recyclerview.widget.RecyclerView.SimpleOnItemTouchListener
import com.afollestad.sectionedrecyclerview.ItemCoord
import com.owncloud.android.datamodel.GalleryItems
import com.owncloud.android.ui.adapter.GalleryAdapter
import me.zhanghai.android.fastscroll.FastScroller
import me.zhanghai.android.fastscroll.PopupTextProvider
import me.zhanghai.android.fastscroll.Predicate
import kotlin.math.ceil
/**
* Custom ViewHelper to get fast scroll working on gallery, which has a gridview and variable height (due to headers)
*
* Copied from me.zhanghai.android.fastscroll.RecyclerViewHelper and heavily modified for gallery structure
*/
class GalleryFastScrollViewHelper(
private val mView: RecyclerView,
private val mPopupTextProvider: PopupTextProvider?
) : FastScroller.ViewHelper {
// used to calculate paddings
private val mTempRect = Rect()
private val layoutManager by lazy { mView.layoutManager as GridLayoutManager }
// header is always 1st in the adapter
private val headerHeight by lazy { getItemHeight(0) }
// the 2nd element is always an item
private val rowHeight by lazy { getItemHeight(1) }
private val columnCount by lazy { layoutManager.spanCount }
private fun getItemHeight(position: Int): Int {
if (mView.childCount <= position) {
return 0
}
val itemView = mView.getChildAt(position)
mView.getDecoratedBoundsWithMargins(itemView, mTempRect)
return mTempRect.height()
}
override fun addOnPreDrawListener(onPreDraw: Runnable) {
mView.addItemDecoration(object : ItemDecoration() {
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
onPreDraw.run()
}
})
}
override fun addOnScrollChangedListener(onScrollChanged: Runnable) {
mView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
onScrollChanged.run()
}
})
}
override fun addOnTouchEventListener(onTouchEvent: Predicate<MotionEvent?>) {
mView.addOnItemTouchListener(object : SimpleOnItemTouchListener() {
override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean {
return onTouchEvent.test(event)
}
override fun onTouchEvent(recyclerView: RecyclerView, event: MotionEvent) {
onTouchEvent.test(event)
}
})
}
override fun getScrollRange(): Int {
val headerCount = getHeaderCount()
val rowCount = getRowCount()
if (headerCount == 0 || rowCount == 0) {
return 0
}
val totalHeaderHeight = headerCount * headerHeight
val totalRowHeight = rowCount * rowHeight
return mView.paddingTop + totalHeaderHeight + totalRowHeight + mView.paddingBottom
}
private fun getHeaderCount(): Int {
val adapter = mView.adapter as GalleryAdapter
return adapter.sectionCount
}
private fun getRowCount(): Int {
val adapter = mView.adapter as GalleryAdapter
if (adapter.sectionCount == 0) return 0
// in each section, the final row may contain less than the max of items
return adapter.files.sumOf { itemCountToRowCount(it.files.size) }
}
/**
* Calculates current absolute offset depending on view state (first visible element)
*/
override fun getScrollOffset(): Int {
val firstItemPosition = getFirstItemAdapterPosition()
if (firstItemPosition == RecyclerView.NO_POSITION) {
return 0
}
val adapter = mView.adapter as GalleryAdapter
val itemCoord: ItemCoord = adapter.getRelativePosition(firstItemPosition)
val isHeader = itemCoord.relativePos() == -1
val seenRowsInPreviousSections = adapter.files
.subList(0, itemCoord.section())
.sumOf { itemCountToRowCount(it.files.size) }
val seenRowsInThisSection = if (isHeader) 0 else itemCountToRowCount(itemCoord.relativePos())
val totalSeenRows = seenRowsInPreviousSections + seenRowsInThisSection
val seenHeaders = when {
isHeader -> itemCoord.section() // don't count the current section header
else -> itemCoord.section() + 1
}
val firstItemTop = getFirstItemOffset()
val totalRowOffset = totalSeenRows * rowHeight
val totalHeaderOffset = seenHeaders * headerHeight
return mView.paddingTop + totalHeaderOffset + totalRowOffset - firstItemTop
}
/**
* Scrolls to an absolute offset
*/
override fun scrollTo(offset: Int) {
mView.stopScroll()
val offsetTmp = offset - mView.paddingTop
val (position, remainingOffset) = findPositionForOffset(offsetTmp)
scrollToPositionWithOffset(position, -remainingOffset)
}
/**
* Given an absolute offset, returns the closest position to that offset (without going over it),
* and the remaining offset
*/
private fun findPositionForOffset(offset: Int): Pair<Int, Int> {
val adapter = mView.adapter as GalleryAdapter
// find section
val sectionStartOffsets = getSectionStartOffsets(adapter.files)
val previousSections = sectionStartOffsets.filter { it <= offset }
val section = previousSections.size - 1
val sectionStartOffset = previousSections.last()
// now calculate where to scroll within the section
var remainingOffset = offset - sectionStartOffset
val positionWithinSection: Int
if (remainingOffset <= headerHeight) {
// header position
positionWithinSection = -1
} else {
// row position
remainingOffset -= headerHeight
val rowCount = remainingOffset / rowHeight
if (rowCount > 0) {
val rowStartIndex = rowCount * columnCount
positionWithinSection = rowStartIndex
remainingOffset -= rowCount * rowHeight
} else {
positionWithinSection = 0 // first item
}
}
val absolutePosition = adapter.getAbsolutePosition(section, positionWithinSection)
return Pair(absolutePosition, remainingOffset)
}
/**
* Returns a list of the offset heights at which the section corresponding to that index starts
*/
private fun getSectionStartOffsets(files: List<GalleryItems>): List<Int> {
val sectionHeights =
files.map { headerHeight + itemCountToRowCount(it.files.size) * rowHeight }
val sectionStartOffsets = sectionHeights.indices.map { i ->
when (i) {
0 -> 0
else -> sectionHeights.subList(0, i).sum()
}
}
return sectionStartOffsets
}
private fun itemCountToRowCount(itemsCount: Int): Int {
return ceil(itemsCount.toDouble() / columnCount).toInt()
}
override fun getPopupText(): String? {
var popupTextProvider: PopupTextProvider? = mPopupTextProvider
if (popupTextProvider == null) {
val adapter = mView.adapter
if (adapter is PopupTextProvider) {
popupTextProvider = adapter
}
}
if (popupTextProvider == null) {
return null
}
val position = getFirstItemAdapterPosition()
return if (position == RecyclerView.NO_POSITION) {
null
} else popupTextProvider.getPopupText(position)
}
private fun getFirstItemAdapterPosition(): Int {
if (mView.childCount == 0) {
return RecyclerView.NO_POSITION
}
val itemView = mView.getChildAt(0)
return layoutManager.getPosition(itemView)
}
private fun getFirstItemOffset(): Int {
if (mView.childCount == 0) {
return RecyclerView.NO_POSITION
}
val itemView = mView.getChildAt(0)
mView.getDecoratedBoundsWithMargins(itemView, mTempRect)
return mTempRect.top
}
private fun scrollToPositionWithOffset(position: Int, offset: Int) {
var offsetTmp = offset
// LinearLayoutManager actually takes offset from paddingTop instead of top of RecyclerView.
offsetTmp -= mView.paddingTop
layoutManager.scrollToPositionWithOffset(position, offsetTmp)
}
}

View file

@ -33,12 +33,7 @@
<com.owncloud.android.ui.EmptyRecyclerView <com.owncloud.android.ui.EmptyRecyclerView
android:id="@+id/list_root" android:id="@+id/list_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent" />
app:fastScrollPopupBgColor="@color/color_accent"
app:fastScrollPopupTextColor="@color/login_text_color"
app:fastScrollThumbColor="@color/color_accent"
app:fastScrollAutoHide="true"
app:fastScrollAutoHideDelay="1500" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<include <include