diff --git a/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java b/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java
index 525e10773e70..b71ff27dbd6d 100644
--- a/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java
+++ b/src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java
@@ -165,7 +165,7 @@ public void showFiles(boolean onDeviceOnly) {
private void setupContent() {
emptyContentIcon.setImageResource(R.drawable.ic_activity_light_grey);
emptyContentProgressBar.getIndeterminateDrawable().setColorFilter(ThemeUtils.primaryAccentColor(this),
- PorterDuff.Mode.SRC_IN);
+ PorterDuff.Mode.SRC_IN);
FileDataStorageManager storageManager = new FileDataStorageManager(getAccount(), getContentResolver());
adapter = new ActivityListAdapter(this, getUserAccountManager(), this, storageManager, getCapabilities(), false);
@@ -174,6 +174,7 @@ private void setupContent() {
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
recyclerView.setLayoutManager(layoutManager);
+ recyclerView.addItemDecoration(new StickyHeaderItemDecoration(adapter));
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
@@ -186,7 +187,7 @@ public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
// synchronize loading state when item count changes
if (!isLoadingActivities && (totalItemCount - visibleItemCount) <= (firstVisibleItemIndex + 5)
- && nextPageUrl != null && !nextPageUrl.isEmpty()) {
+ && nextPageUrl != null && !nextPageUrl.isEmpty()) {
// Almost reached the end, continue to load new activities
mActionListener.loadActivities(nextPageUrl);
}
diff --git a/src/main/java/com/owncloud/android/ui/activities/StickyHeaderItemDecoration.java b/src/main/java/com/owncloud/android/ui/activities/StickyHeaderItemDecoration.java
new file mode 100644
index 000000000000..bdcf81795acd
--- /dev/null
+++ b/src/main/java/com/owncloud/android/ui/activities/StickyHeaderItemDecoration.java
@@ -0,0 +1,122 @@
+/*
+ * Nextcloud Android client application
+ *
+
+ * Copyright (C) 2019 Sevastyan Savanyuk
+ * Copyright (C) 2019 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package com.owncloud.android.ui.activities;
+
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.owncloud.android.ui.adapter.StickyHeaderAdapter;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+public class StickyHeaderItemDecoration extends RecyclerView.ItemDecoration {
+ private final StickyHeaderAdapter adapter;
+
+
+ public StickyHeaderItemDecoration(StickyHeaderAdapter stickyHeaderAdapter) {
+ this.adapter = stickyHeaderAdapter;
+ }
+
+ @Override
+ public void onDrawOver(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
+ super.onDrawOver(canvas, parent, state);
+
+ View topChild = parent.getChildAt(0);
+ if (topChild == null) {
+ return;
+ }
+ int topChildPosition = parent.getChildAdapterPosition(topChild);
+
+ if (topChildPosition == RecyclerView.NO_POSITION) {
+ return;
+ }
+ View currentHeader = getHeaderViewForItem(topChildPosition, parent);
+ fixLayoutSize(parent, currentHeader);
+ int contactPoint = currentHeader.getBottom();
+ View childInContact = getChildInContact(parent, contactPoint);
+
+ if (childInContact == null) {
+ return;
+ }
+
+ if (adapter.isHeader(parent.getChildAdapterPosition(childInContact))) {
+ moveHeader(canvas, currentHeader, childInContact);
+ return;
+ }
+
+ drawHeader(canvas, currentHeader);
+ }
+
+ private void drawHeader(Canvas canvas, View header) {
+ canvas.save();
+ canvas.translate(0, 0);
+ header.draw(canvas);
+ canvas.restore();
+ }
+
+ private void moveHeader(Canvas canvas, View currentHeader, View nextHeader) {
+ canvas.save();
+ canvas.translate(0, nextHeader.getTop() - currentHeader.getHeight());
+ currentHeader.draw(canvas);
+ canvas.restore();
+ }
+
+ private View getChildInContact(RecyclerView parent, int contactPoint) {
+ View childInContact = null;
+ for (int i = 0; i < parent.getChildCount(); i++) {
+ View currentChild = parent.getChildAt(i);
+ if (currentChild.getBottom() > contactPoint && currentChild.getTop() <= contactPoint) {
+ childInContact = currentChild;
+ break;
+ }
+ }
+ return childInContact;
+ }
+
+ private View getHeaderViewForItem(int itemPosition, RecyclerView parent) {
+ int headerPosition = adapter.getHeaderPositionForItem(itemPosition);
+ int layoutId = adapter.getHeaderLayout(itemPosition);
+ View header = LayoutInflater.from(parent.getContext()).inflate(layoutId, parent, false);
+ header.setBackgroundColor(Color.WHITE);
+ adapter.bindHeaderData(header, headerPosition);
+ return header;
+ }
+
+ private void fixLayoutSize(ViewGroup parent, View view) {
+
+ // Specs for parent (RecyclerView)
+ int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
+ int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);
+
+ // Specs for children (headers)
+ int childWidthSpec = ViewGroup.getChildMeasureSpec(widthSpec, parent.getPaddingLeft() + parent.getPaddingRight(), view.getLayoutParams().width);
+ int childHeightSpec = ViewGroup.getChildMeasureSpec(heightSpec, parent.getPaddingTop() + parent.getPaddingBottom(), view.getLayoutParams().height);
+
+ view.measure(childWidthSpec, childHeightSpec);
+ view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight());
+ }
+
+
+}
diff --git a/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java b/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java
index 35cd3420a366..a7f38cdae723 100644
--- a/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java
+++ b/src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java
@@ -84,7 +84,7 @@
/**
* Adapter for the activity view
*/
-public class ActivityListAdapter extends RecyclerView.Adapter {
+public class ActivityListAdapter extends RecyclerView.Adapter implements StickyHeaderAdapter {
static final int HEADER_TYPE = 100;
static final int ACTIVITY_TYPE = 101;
@@ -173,11 +173,10 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int positi
}
if (activity.getRichSubjectElement() != null &&
- !TextUtils.isEmpty(activity.getRichSubjectElement().getRichSubject())) {
+ !TextUtils.isEmpty(activity.getRichSubjectElement().getRichSubject())) {
activityViewHolder.subject.setVisibility(View.VISIBLE);
activityViewHolder.subject.setMovementMethod(LinkMovementMethod.getInstance());
- activityViewHolder.subject.setText(addClickablePart(activity.getRichSubjectElement()),
- TextView.BufferType.SPANNABLE);
+ activityViewHolder.subject.setText(addClickablePart(activity.getRichSubjectElement()), TextView.BufferType.SPANNABLE);
activityViewHolder.subject.setVisibility(View.VISIBLE);
} else if (!TextUtils.isEmpty(activity.getSubject())) {
activityViewHolder.subject.setVisibility(View.VISIBLE);
@@ -198,8 +197,7 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int positi
}
if (activity.getRichSubjectElement() != null &&
- activity.getRichSubjectElement().getRichObjectList().size() > 0) {
-
+ activity.getRichSubjectElement().getRichObjectList().size() > 0) {
activityViewHolder.list.setVisibility(View.VISIBLE);
activityViewHolder.list.removeAllViews();
@@ -325,23 +323,23 @@ private void setBitmap(OCFile file, ImageView fileIcon, boolean isDetailView) {
private void downloadIcon(String icon, ImageView itemViewType) {
GenericRequestBuilder requestBuilder = Glide.with(context)
- .using(Glide.buildStreamModelLoader(Uri.class, context), InputStream.class)
- .from(Uri.class)
- .as(SVG.class)
- .transcode(new SvgDrawableTranscoder(), PictureDrawable.class)
- .sourceEncoder(new StreamEncoder())
- .cacheDecoder(new FileToStreamDecoder<>(new SvgDecoder()))
- .decoder(new SvgDecoder())
- .placeholder(R.drawable.ic_activity)
- .error(R.drawable.ic_activity)
- .animate(android.R.anim.fade_in)
- .listener(new SvgSoftwareLayerSetter<>());
+ .using(Glide.buildStreamModelLoader(Uri.class, context), InputStream.class)
+ .from(Uri.class)
+ .as(SVG.class)
+ .transcode(new SvgDrawableTranscoder(), PictureDrawable.class)
+ .sourceEncoder(new StreamEncoder())
+ .cacheDecoder(new FileToStreamDecoder<>(new SvgDecoder()))
+ .decoder(new SvgDecoder())
+ .placeholder(R.drawable.ic_activity)
+ .error(R.drawable.ic_activity)
+ .animate(android.R.anim.fade_in)
+ .listener(new SvgSoftwareLayerSetter<>());
Uri uri = Uri.parse(icon);
requestBuilder
- .diskCacheStrategy(DiskCacheStrategy.SOURCE)
- .load(uri)
- .into(itemViewType);
+ .diskCacheStrategy(DiskCacheStrategy.SOURCE)
+ .load(uri)
+ .into(itemViewType);
}
private SpannableStringBuilder addClickablePart(RichElement richElement) {
@@ -418,7 +416,7 @@ private int getThumbnailDimension() {
CharSequence getHeaderDateString(Context context, long modificationTimestamp) {
if ((System.currentTimeMillis() - modificationTimestamp) < DateUtils.WEEK_IN_MILLIS) {
return DisplayUtils.getRelativeDateTimeString(context, modificationTimestamp, DateUtils.DAY_IN_MILLIS,
- DateUtils.WEEK_IN_MILLIS, 0);
+ DateUtils.WEEK_IN_MILLIS, 0);
} else {
String pattern = "EEEE, MMMM d";
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR2) {
@@ -428,6 +426,37 @@ CharSequence getHeaderDateString(Context context, long modificationTimestamp) {
}
}
+
+ @Override
+ public int getHeaderPositionForItem(int itemPosition) {
+ int headerPosition = itemPosition;
+ while (headerPosition >= 0) {
+ if (this.isHeader(headerPosition)) {
+ break;
+ }
+ headerPosition -= 1;
+ }
+ return headerPosition;
+ }
+
+
+ @Override
+ public int getHeaderLayout(int headerPosition) {
+ return R.layout.activity_list_item_header;
+ }
+
+ @Override
+ public void bindHeaderData(View header, int headerPosition) {
+ TextView textView = header.findViewById(R.id.title_header);
+ String headline = (String) values.get(headerPosition);
+ textView.setText(headline);
+ }
+
+ @Override
+ public boolean isHeader(int itemPosition) {
+ return this.getItemViewType(itemPosition) == HEADER_TYPE;
+ }
+
protected class ActivityViewHolder extends RecyclerView.ViewHolder {
private final ImageView activityIcon;
diff --git a/src/main/java/com/owncloud/android/ui/adapter/StickyHeaderAdapter.java b/src/main/java/com/owncloud/android/ui/adapter/StickyHeaderAdapter.java
new file mode 100644
index 000000000000..1ba23a9c27cf
--- /dev/null
+++ b/src/main/java/com/owncloud/android/ui/adapter/StickyHeaderAdapter.java
@@ -0,0 +1,56 @@
+/*
+ * Nextcloud Android client application
+ *
+
+ * Copyright (C) 2019 Sevastyan Savanyuk
+ * Copyright (C) 2019 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package com.owncloud.android.ui.adapter;
+
+import android.view.View;
+
+import com.owncloud.android.ui.activities.StickyHeaderItemDecoration;
+
+public interface StickyHeaderAdapter {
+ /**
+ * This method gets called by {@link StickyHeaderItemDecoration} to fetch the position of the header item in the adapter
+ * that is used for (represents) item at specified position.
+ * @param itemPosition int. Adapter's position of the item for which to do the search of the position of the header item.
+ * @return int. Position of the header item in the adapter.
+ */
+ int getHeaderPositionForItem(int itemPosition);
+
+ /**
+ * This method gets called by {@link StickyHeaderItemDecoration} to get layout resource id for the header item at specified adapter's position.
+ * @param headerPosition int. Position of the header item in the adapter.
+ * @return int. Layout resource id.
+ */
+ int getHeaderLayout(int headerPosition);
+
+ /**
+ * This method gets called by {@link StickyHeaderItemDecoration} to setup the header View.
+ * @param header View. Header to set the data on.
+ * @param headerPosition int. Position of the header item in the adapter.
+ */
+ void bindHeaderData(View header, int headerPosition);
+
+ /**
+ * This method gets called by {@link StickyHeaderItemDecoration} to verify whether the item represents a header.
+ * @param itemPosition int.
+ * @return true, if item at the specified adapter's position represents a header.
+ */
+ boolean isHeader(int itemPosition);
+}
diff --git a/src/main/res/layout/activity_list_item_header.xml b/src/main/res/layout/activity_list_item_header.xml
index 3eee1c3f7526..6c8e3de862f7 100644
--- a/src/main/res/layout/activity_list_item_header.xml
+++ b/src/main/res/layout/activity_list_item_header.xml
@@ -6,11 +6,11 @@
-
\ No newline at end of file
+
diff --git a/src/main/res/values/dims.xml b/src/main/res/values/dims.xml
index 0806007d9558..1db7bb7af6b7 100644
--- a/src/main/res/values/dims.xml
+++ b/src/main/res/values/dims.xml
@@ -95,7 +95,7 @@
32dp
24dp
-3dp
- 20sp
+ 16sp
-3dp
48dp
32dp
diff --git a/src/test/java/com/owncloud/android/ui/adapter/ActivityListAdapterTest.java b/src/test/java/com/owncloud/android/ui/adapter/ActivityListAdapterTest.java
new file mode 100644
index 000000000000..dd7c5859baa8
--- /dev/null
+++ b/src/test/java/com/owncloud/android/ui/adapter/ActivityListAdapterTest.java
@@ -0,0 +1,137 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Alex Plutta
+ * Copyright (C) 2019 Alex Plutta
+ * Copyright (C) 2019 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package com.owncloud.android.ui.adapter;
+
+import com.owncloud.android.lib.resources.activities.model.Activity;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.mockito.internal.util.reflection.FieldSetter;
+
+import java.util.ArrayList;
+
+public final class ActivityListAdapterTest {
+
+
+ @Mock
+ private ActivityListAdapter activityListAdapter;
+
+ @Before
+ public void setUp() throws NoSuchFieldException {
+ MockitoAnnotations.initMocks(this);
+ MockitoAnnotations.initMocks(activityListAdapter);
+ FieldSetter.setField(activityListAdapter, activityListAdapter.getClass().getDeclaredField("values"), new ArrayList<>());
+ }
+
+ @Test
+ public void isHeader__ObjectIsHeader_ReturnTrue() {
+ Object header = "Hello";
+ Object activity = Mockito.mock(Activity.class);
+
+ Mockito.when(activityListAdapter.isHeader(0)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getItemViewType(0)).thenCallRealMethod();
+
+ activityListAdapter.values.add(header);
+ activityListAdapter.values.add(activity);
+
+ final boolean result = activityListAdapter.isHeader(0);
+ Assert.assertTrue(result);
+ }
+
+ @Test
+ public void isHeader__ObjectIsActivity_ReturnFalse() {
+ Object header = "Hello";
+ Object activity = Mockito.mock(Activity.class);
+
+ Mockito.when(activityListAdapter.isHeader(1)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getItemViewType(1)).thenCallRealMethod();
+
+ activityListAdapter.values.add(header);
+ activityListAdapter.values.add(activity);
+ Assert.assertFalse(activityListAdapter.isHeader(1));
+ }
+
+ @Test
+ public void getHeaderPositionForItem__AdapterIsEmpty_ReturnZero(){
+ Mockito.when(activityListAdapter.isHeader(0)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getItemViewType(0)).thenCallRealMethod();
+
+ Assert.assertEquals(0,activityListAdapter.getHeaderPositionForItem(0));
+ }
+
+ @Test
+ public void getHeaderPositionForItem__ItemIsHeader_ReturnCurrentItem() {
+ Object header = "Hello";
+ Object activity = Mockito.mock(Activity.class);
+
+ Mockito.when(activityListAdapter.isHeader(0)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getItemViewType(0)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.isHeader(1)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getItemViewType(1)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.isHeader(2)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getItemViewType(2)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getHeaderPositionForItem(2)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.isHeader(3)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getItemViewType(3)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getHeaderPositionForItem(3)).thenCallRealMethod();
+
+
+ activityListAdapter.values.add(header);
+ activityListAdapter.values.add(activity);
+ activityListAdapter.values.add(header);
+ activityListAdapter.values.add(activity);
+
+
+ Assert.assertEquals(2, activityListAdapter.getHeaderPositionForItem(2));
+
+ }
+
+ @Test
+ public void getHeaderPositionForItem__ItemIsActivity_ReturnNextHeader() {
+ Object header = "Hello";
+ Object activity = Mockito.mock(Activity.class);
+
+ Mockito.when(activityListAdapter.isHeader(0)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getItemViewType(0)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getHeaderPositionForItem(0)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.isHeader(1)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getItemViewType(1)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getHeaderPositionForItem(1)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.isHeader(2)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getItemViewType(2)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getHeaderPositionForItem(2)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.isHeader(3)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getItemViewType(3)).thenCallRealMethod();
+ Mockito.when(activityListAdapter.getHeaderPositionForItem(3)).thenCallRealMethod();
+
+ activityListAdapter.values.add(header);
+ activityListAdapter.values.add(activity);
+ activityListAdapter.values.add(header);
+ activityListAdapter.values.add(activity);
+
+ Assert.assertEquals(2, activityListAdapter.getHeaderPositionForItem(2));
+ }
+
+}