Browse Source

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

Fast scrolling fixes
Álvaro Brey 3 years ago
parent
commit
db85ebba94

+ 1 - 1
app/build.gradle

@@ -272,7 +272,7 @@ dependencies {
     implementation "com.google.android.exoplayer:exoplayer:$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
     implementation 'io.github.elye:loaderviewlibrary:3.0.0'

+ 61 - 0
app/src/main/java/com/nextcloud/utils/view/FastScroll.kt

@@ -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
+                )
+            }
+        )
+    }
+}

+ 2 - 3
app/src/main/java/com/owncloud/android/ui/EmptyRecyclerView.java

@@ -25,14 +25,13 @@ import android.content.Context;
 import android.util.AttributeSet;
 import android.view.View;
 
-import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView;
-
 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
  */
-public class EmptyRecyclerView extends FastScrollRecyclerView {
+public class EmptyRecyclerView extends RecyclerView {
     private View mEmptyView;
     private boolean hasFooter = false;
 

+ 4 - 1
app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java

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

+ 4 - 4
app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt

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

+ 3 - 4
app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java

@@ -74,7 +74,6 @@ import com.owncloud.android.utils.theme.CapabilityUtils;
 import com.owncloud.android.utils.theme.ThemeAvatarUtils;
 import com.owncloud.android.utils.theme.ThemeColorUtils;
 import com.owncloud.android.utils.theme.ThemeDrawableUtils;
-import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView;
 
 import java.io.File;
 import java.text.SimpleDateFormat;
@@ -90,14 +89,14 @@ import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
 import androidx.recyclerview.widget.RecyclerView;
 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.
  */
 public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
     implements DisplayUtils.AvatarGenerationListener,
-    CommonOCFileListAdapterInterface,
-    FastScrollRecyclerView.SectionedAdapter {
+    CommonOCFileListAdapterInterface, PopupTextProvider {
 
     private static final int showFilenameColumnThreshold = 4;
     private final String userId;
@@ -909,7 +908,7 @@ public class OCFileListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
 
     @NonNull
     @Override
-    public String getSectionName(int position) {
+    public String getPopupText(int position) {
         OCFile file = getItem(position);
 
         if (file == null) {

+ 4 - 14
app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java

@@ -27,6 +27,7 @@ import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 
+import com.nextcloud.utils.view.FastScroll;
 import com.owncloud.android.R;
 import com.owncloud.android.datamodel.OCFile;
 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.asynctasks.GallerySearchTask;
 import com.owncloud.android.ui.events.ChangeMenuEvent;
+import com.owncloud.android.ui.fragment.util.GalleryFastScrollViewHelper;
 
 import androidx.annotation.NonNull;
 import androidx.fragment.app.FragmentActivity;
@@ -109,26 +111,14 @@ public class GalleryFragment extends OCFileListFragment {
                                       themeColorUtils,
                                       themeDrawableUtils);
 
-//        val spacing = resources.getDimensionPixelSize(R.dimen.media_grid_spacing)
-//        binding.list.addItemDecoration(MediaGridItemDecoration(spacing))
         setRecyclerViewAdapter(mAdapter);
 
 
         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);
         getRecyclerView().setLayoutManager(layoutManager);
+
+        FastScroll.applyFastScroll(getRecyclerView(), new GalleryFastScrollViewHelper(getRecyclerView(), mAdapter));
     }
 
     @Override

+ 3 - 0
app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java

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

+ 264 - 0
app/src/main/java/com/owncloud/android/ui/fragment/util/GalleryFastScrollViewHelper.kt

@@ -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)
+    }
+}

+ 1 - 6
app/src/main/res/layout/list_fragment.xml

@@ -33,12 +33,7 @@
         <com.owncloud.android.ui.EmptyRecyclerView
             android:id="@+id/list_root"
             android:layout_width="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" />
+            android:layout_height="match_parent" />
     </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
 
     <include