Эх сурвалжийг харах

Merge pull request #4294 from AlexNi245/#2216-activity-data-divers-design

Activity data dividers
Andy Scherzinger 5 жил өмнө
parent
commit
b36067d458

+ 3 - 2
src/main/java/com/owncloud/android/ui/activities/ActivitiesActivity.java

@@ -165,7 +165,7 @@ public class ActivitiesActivity extends FileActivity implements ActivityListInte
     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 @@ public class ActivitiesActivity extends FileActivity implements ActivityListInte
         LinearLayoutManager layoutManager = new LinearLayoutManager(this);
 
         recyclerView.setLayoutManager(layoutManager);
+        recyclerView.addItemDecoration(new StickyHeaderItemDecoration(adapter));
         recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
 
             @Override
@@ -186,7 +187,7 @@ public class ActivitiesActivity extends FileActivity implements ActivityListInte
 
                 // 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);
                 }

+ 122 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+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());
+    }
+
+
+}

+ 50 - 21
src/main/java/com/owncloud/android/ui/adapter/ActivityListAdapter.java

@@ -84,7 +84,7 @@ import androidx.recyclerview.widget.RecyclerView;
 /**
  * Adapter for the activity view
  */
-public class ActivityListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
+public class ActivityListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements StickyHeaderAdapter {
 
     static final int HEADER_TYPE = 100;
     static final int ACTIVITY_TYPE = 101;
@@ -173,11 +173,10 @@ public class ActivityListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
             }
 
             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 class ActivityListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
             }
 
             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 @@ public class ActivityListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 
     private void downloadIcon(String icon, ImageView itemViewType) {
         GenericRequestBuilder<Uri, InputStream, SVG, PictureDrawable> 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 @@ public class ActivityListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
     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 @@ public class ActivityListAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
         }
     }
 
+
+    @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;

+ 56 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+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);
+}

+ 2 - 2
src/main/res/layout/activity_list_item_header.xml

@@ -6,11 +6,11 @@
     <TextView
         android:id="@+id/title_header"
         android:layout_width="match_parent"
-        android:layout_height="wrap_content"
+        android:layout_height="60dp"
         android:layout_marginLeft="@dimen/standard_list_item_size"
         android:layout_marginStart="@dimen/standard_list_item_size"
         android:layout_marginTop="10dp"
         android:text="@string/placeholder_filename"
         android:textSize="@dimen/activity_list_item_title_header_text_size"/>
 
-</LinearLayout>
+</LinearLayout>

+ 1 - 1
src/main/res/values/dims.xml

@@ -95,7 +95,7 @@
     <dimen name="activity_icon_height">32dp</dimen>
     <dimen name="activity_icon_layout_right_end_margin">24dp</dimen>
     <dimen name="activity_list_item_grid_layout_left_start_margin">-3dp</dimen>
-    <dimen name="activity_list_item_title_header_text_size">20sp</dimen>
+    <dimen name="activity_list_item_title_header_text_size">16sp</dimen>
     <dimen name="activity_list_layout_recycler_view_margin">-3dp</dimen>
     <dimen name="activity_row_layout_height">48dp</dimen>
     <dimen name="notification_icon_width">32dp</dimen>

+ 137 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+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));
+    }
+
+}