From 1007b4d635962d23043d15997132aea80d35280e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 21 Oct 2022 11:04:40 -0400 Subject: [PATCH] Reduce flakiness of our dependencies. --- app/build.gradle | 20 +- .../lock/v2/ConfirmKbsPinFragment.java | 2 +- .../lock/v2/CreateKbsPinFragment.java | 2 +- .../CorrectedPreferenceFragment.java | 6 +- .../widgets/ColorPickerPreference.java | 253 --- ...rPickerPreferenceDialogFragmentCompat.java | 65 - .../video/exo/SimpleExoPlayerPool.kt | 1 - .../webrtc_call_screen_circle_green.xml | 2 +- .../webrtc_call_screen_circle_red.xml | 2 +- .../webrtc_call_screen_circle_green.xml | 2 +- .../webrtc_call_screen_circle_red.xml | 2 +- .../conversation_item_footer_incoming.xml | 2 +- .../conversation_item_footer_outgoing.xml | 2 +- app/src/main/res/layout/expiration_dialog.xml | 35 - app/src/main/res/layout/item_color.xml | 2 +- .../res/layout/media_selection_activity.xml | 4 +- ...ion_activity_text_selected_constraints.xml | 4 +- .../main/res/layout/review_banner_view.xml | 2 +- .../main/res/layout/stories_landing_item.xml | 2 +- .../stories_multiselect_forward_activity.xml | 4 +- .../stories_text_post_text_entry_content.xml | 2 +- .../res/layout/v2_media_image_editor_hud.xml | 2 +- ...media_image_editor_text_entry_fragment.xml | 2 +- ...c_call_participant_recycler_empty_item.xml | 2 +- .../webrtc_call_participant_recycler_item.xml | 2 +- dependencies.gradle | 5 +- gradle/verification-metadata.xml | 91 ++ photoview/build.gradle | 62 + photoview/src/main/AndroidManifest.xml | 6 + .../github/chrisbanes/photoview/Compat.java | 39 + .../photoview/CustomGestureDetector.java | 214 +++ .../photoview/OnGestureListener.java | 28 + .../photoview/OnMatrixChangedListener.java | 18 + .../photoview/OnOutsidePhotoTapListener.java | 14 + .../photoview/OnPhotoTapListener.java | 22 + .../photoview/OnScaleChangedListener.java | 17 + .../photoview/OnSingleFlingListener.java | 21 + .../photoview/OnViewDragListener.java | 16 + .../photoview/OnViewTapListener.java | 16 + .../chrisbanes/photoview/PhotoView.java | 257 ++++ .../photoview/PhotoViewAttacher.java | 823 ++++++++++ .../com/github/chrisbanes/photoview/Util.java | 37 + settings.gradle | 2 + sticky-header-grid/README.md | 2 + sticky-header-grid/build.gradle | 62 + .../src/main/AndroidManifest.xml | 6 + .../StickyHeaderGridAdapter.java | 613 ++++++++ .../StickyHeaderGridLayoutManager.java | 1368 +++++++++++++++++ 48 files changed, 3761 insertions(+), 402 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java delete mode 100644 app/src/main/res/layout/expiration_dialog.xml create mode 100644 photoview/build.gradle create mode 100644 photoview/src/main/AndroidManifest.xml create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/Compat.java create mode 100755 photoview/src/main/java/com/github/chrisbanes/photoview/CustomGestureDetector.java create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/OnGestureListener.java create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/OnMatrixChangedListener.java create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/OnOutsidePhotoTapListener.java create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/OnPhotoTapListener.java create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/OnScaleChangedListener.java create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/OnSingleFlingListener.java create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/OnViewDragListener.java create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/OnViewTapListener.java create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/PhotoView.java create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/PhotoViewAttacher.java create mode 100644 photoview/src/main/java/com/github/chrisbanes/photoview/Util.java create mode 100644 sticky-header-grid/README.md create mode 100644 sticky-header-grid/build.gradle create mode 100644 sticky-header-grid/src/main/AndroidManifest.xml create mode 100644 sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridAdapter.java create mode 100644 sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridLayoutManager.java diff --git a/app/build.gradle b/app/build.gradle index 19559b9c5..68f936b65 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,9 +16,6 @@ repositories { includeGroupByRegex "org\\.signal.*" } } - maven { - url "https://www.jitpack.io" - } google() mavenCentral() @@ -29,10 +26,6 @@ repositories { jcenter { content { includeVersion "mobi.upod", "time-duration-picker", "1.1.3" - includeVersion "cn.carbswang.android", "NumberPickerView", "1.0.9" - includeVersion "com.takisoft.fix", "colorpicker", "0.9.1" - includeVersion "com.codewaves.stickyheadergrid", "stickyheadergrid", "0.9.4" - includeVersion "com.google.android", "flexbox", "0.3.0" } } } @@ -474,6 +467,8 @@ dependencies { implementation project(':contacts') implementation project(':qr') implementation project(':sms-exporter') + implementation project(':sticky-header-grid') + implementation project(':photoview') implementation libs.libsignal.android implementation libs.google.protobuf.javalite @@ -494,7 +489,6 @@ dependencies { implementation libs.emilsjolander.stickylistheaders implementation libs.jpardogo.materialtabstrip implementation libs.apache.httpclient.android - implementation libs.photoview implementation libs.glide.glide implementation libs.roundedimageview implementation libs.materialish.progress @@ -503,12 +497,10 @@ dependencies { implementation libs.google.zxing.android.integration implementation libs.time.duration.picker implementation libs.google.zxing.core + implementation libs.google.flexbox implementation (libs.subsampling.scale.image.view) { exclude group: 'com.android.support', module: 'support-annotations' } - implementation (libs.numberpickerview) { - exclude group: 'com.android.support', module: 'appcompat-v7' - } implementation (libs.android.tooltips) { exclude group: 'com.android.support', module: 'appcompat-v7' } @@ -517,15 +509,9 @@ dependencies { exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' } implementation libs.stream - implementation (libs.colorpicker) { - exclude group: 'com.android.support', module: 'appcompat-v7' - exclude group: 'com.android.support', module: 'recyclerview-v7' - } implementation libs.lottie - implementation libs.stickyheadergrid - implementation libs.signal.android.database.sqlcipher implementation libs.androidx.sqlite diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinFragment.java b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinFragment.java index ca06afb6c..c3c0265d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/ConfirmKbsPinFragment.java @@ -87,7 +87,7 @@ public class ConfirmKbsPinFragment extends BaseKbsPinFragment onConfirmPin(e.getUserEntry(), e.getKeyboard(), args.getIsPinChange())); viewModel.getErrorEvents().observe(getViewLifecycleOwner(), e -> { if (e == CreateKbsPinViewModel.PinErrorEvent.WEAK_PIN) { - getLabel().setText(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.red), + getLabel().setText(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.red_500), getString(R.string.CreateKbsPinFragment__choose_a_stronger_pin))); shake(getInput(), () -> getInput().getText().clear()); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java index e7d3a21e3..4f74616d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -18,8 +18,6 @@ import androidx.recyclerview.widget.RecyclerView; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.CustomDefaultPreference; -import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreference; -import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreferenceDialogFragmentCompat; public abstract class CorrectedPreferenceFragment extends PreferenceFragmentCompat { @@ -40,9 +38,7 @@ public abstract class CorrectedPreferenceFragment extends PreferenceFragmentComp public void onDisplayPreferenceDialog(Preference preference) { DialogFragment dialogFragment = null; - if (preference instanceof ColorPickerPreference) { - dialogFragment = ColorPickerPreferenceDialogFragmentCompat.newInstance(preference.getKey()); - } else if (preference instanceof CustomDefaultPreference) { + if (preference instanceof CustomDefaultPreference) { dialogFragment = CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.newInstance(preference.getKey()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java deleted file mode 100644 index aec818649..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java +++ /dev/null @@ -1,253 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Color; -import android.graphics.drawable.Drawable; -import android.text.TextUtils; -import android.util.AttributeSet; -import android.widget.ImageView; - -import androidx.core.content.ContextCompat; -import androidx.core.content.res.TypedArrayUtils; -import androidx.preference.DialogPreference; -import androidx.preference.PreferenceViewHolder; - -import com.takisoft.colorpicker.ColorPickerDialog; -import com.takisoft.colorpicker.ColorPickerDialog.Size; -import com.takisoft.colorpicker.ColorStateDrawable; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; - -public class ColorPickerPreference extends DialogPreference { - - private static final String TAG = Log.tag(ColorPickerPreference.class); - - private int[] colors; - private CharSequence[] colorDescriptions; - private int color; - private int columns; - private int size; - private boolean sortColors; - - private ImageView colorWidget; - private OnPreferenceChangeListener listener; - - public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - - TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ColorPickerPreference, defStyleAttr, 0); - - int colorsId = a.getResourceId(R.styleable.ColorPickerPreference_colors, R.array.color_picker_default_colors); - - if (colorsId != 0) { - colors = context.getResources().getIntArray(colorsId); - } - - colorDescriptions = a.getTextArray(R.styleable.ColorPickerPreference_colorDescriptions); - color = a.getColor(R.styleable.ColorPickerPreference_currentColor, 0); - columns = a.getInt(R.styleable.ColorPickerPreference_columns, 3); - size = a.getInt(R.styleable.ColorPickerPreference_colorSize, 2); - sortColors = a.getBoolean(R.styleable.ColorPickerPreference_sortColors, false); - - a.recycle(); - - setWidgetLayoutResource(R.layout.preference_widget_color_swatch); - } - - public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr) { - this(context, attrs, defStyleAttr, 0); - } - - @SuppressLint("RestrictedApi") - public ColorPickerPreference(Context context, AttributeSet attrs) { - this(context, attrs, TypedArrayUtils.getAttr(context, R.attr.dialogPreferenceStyle, - android.R.attr.dialogPreferenceStyle)); - } - - public ColorPickerPreference(Context context) { - this(context, null); - } - - @Override - public void setOnPreferenceChangeListener(OnPreferenceChangeListener listener) { - super.setOnPreferenceChangeListener(listener); - this.listener = listener; - } - - @Override - public void onBindViewHolder(PreferenceViewHolder holder) { - super.onBindViewHolder(holder); - - colorWidget = (ImageView) holder.findViewById(R.id.color_picker_widget); - setColorOnWidget(color); - } - - private void setColorOnWidget(int color) { - if (colorWidget == null) { - return; - } - - Drawable[] colorDrawable = new Drawable[] - {ContextCompat.getDrawable(getContext(), R.drawable.colorpickerpreference_pref_swatch)}; - colorWidget.setImageDrawable(new ColorStateDrawable(colorDrawable, color)); - } - - /** - * Returns the current color. - * - * @return The current color. - */ - public int getColor() { - return color; - } - - /** - * Sets the current color. - * - * @param color The current color. - */ - public void setColor(int color) { - setInternalColor(color, false); - } - - /** - * Returns all of the available colors. - * - * @return The available colors. - */ - public int[] getColors() { - return colors; - } - - /** - * Sets the available colors. - * - * @param colors The available colors. - */ - public void setColors(int[] colors) { - this.colors = colors; - } - - /** - * Returns whether the available colors should be sorted automatically based on their HSV - * values. - * - * @return Whether the available colors should be sorted automatically based on their HSV - * values. - */ - public boolean isSortColors() { - return sortColors; - } - - /** - * Sets whether the available colors should be sorted automatically based on their HSV - * values. The sorting does not modify the order of the original colors supplied via - * {@link #setColors(int[])} or the XML attribute {@code app:colors}. - * - * @param sortColors Whether the available colors should be sorted automatically based on their - * HSV values. - */ - public void setSortColors(boolean sortColors) { - this.sortColors = sortColors; - } - - /** - * Returns the available colors' descriptions that can be used by accessibility services. - * - * @return The available colors' descriptions. - */ - public CharSequence[] getColorDescriptions() { - return colorDescriptions; - } - - /** - * Sets the available colors' descriptions that can be used by accessibility services. - * - * @param colorDescriptions The available colors' descriptions. - */ - public void setColorDescriptions(CharSequence[] colorDescriptions) { - this.colorDescriptions = colorDescriptions; - } - - /** - * Returns the number of columns to be used in the picker dialog for displaying the available - * colors. If the value is less than or equals to 0, the number of columns will be determined - * automatically by the system using FlexboxLayoutManager. - * - * @return The number of columns to be used in the picker dialog. - * @see com.google.android.flexbox.FlexboxLayoutManager - */ - public int getColumns() { - return columns; - } - - /** - * Sets the number of columns to be used in the picker dialog for displaying the available - * colors. If the value is less than or equals to 0, the number of columns will be determined - * automatically by the system using FlexboxLayoutManager. - * - * @param columns The number of columns to be used in the picker dialog. Use 0 to set it to - * 'auto' mode. - * @see com.google.android.flexbox.FlexboxLayoutManager - */ - public void setColumns(int columns) { - this.columns = columns; - } - - /** - * Returns the size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * - * @return The size of the color swatches in the dialog. - * @see ColorPickerDialog#SIZE_SMALL - * @see ColorPickerDialog#SIZE_LARGE - */ - @Size - public int getSize() { - return size; - } - - /** - * Sets the size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * - * @param size The size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * @see ColorPickerDialog#SIZE_SMALL - * @see ColorPickerDialog#SIZE_LARGE - */ - public void setSize(@Size int size) { - this.size = size; - } - - private void setInternalColor(int color, boolean force) { - int oldColor = getPersistedInt(0); - - boolean changed = oldColor != color; - - if (changed || force) { - this.color = color; - - persistInt(color); - - setColorOnWidget(color); - - if (listener != null) listener.onPreferenceChange(this, color); - notifyChanged(); - } - } - - @Override - protected Object onGetDefaultValue(TypedArray a, int index) { - return a.getString(index); - } - - @Override - protected void onSetInitialValue(boolean restoreValue, Object defaultValueObj) { - final String defaultValue = (String) defaultValueObj; - setInternalColor(restoreValue ? getPersistedInt(0) : (!TextUtils.isEmpty(defaultValue) ? Color.parseColor(defaultValue) : 0), true); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java deleted file mode 100644 index 0066e1e1c..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - -import android.app.Dialog; -import android.content.DialogInterface; -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.preference.PreferenceDialogFragmentCompat; - -import com.takisoft.colorpicker.ColorPickerDialog; -import com.takisoft.colorpicker.OnColorSelectedListener; - -public class ColorPickerPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat implements OnColorSelectedListener { - - private int pickedColor; - - public static ColorPickerPreferenceDialogFragmentCompat newInstance(String key) { - ColorPickerPreferenceDialogFragmentCompat fragment = new ColorPickerPreferenceDialogFragmentCompat(); - Bundle b = new Bundle(1); - b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key); - fragment.setArguments(b); - return fragment; - } - - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - ColorPickerPreference pref = getColorPickerPreference(); - - ColorPickerDialog.Params params = new ColorPickerDialog.Params.Builder(getContext()) - .setSelectedColor(pref.getColor()) - .setColors(pref.getColors()) - .setColorContentDescriptions(pref.getColorDescriptions()) - .setSize(pref.getSize()) - .setSortColors(pref.isSortColors()) - .setColumns(pref.getColumns()) - .build(); - - ColorPickerDialog dialog = new ColorPickerDialog(getActivity(), this, params); - dialog.setTitle(pref.getDialogTitle()); - - return dialog; - } - - @Override - public void onDialogClosed(boolean positiveResult) { - ColorPickerPreference preference = getColorPickerPreference(); - - if (positiveResult) { - preference.setColor(pickedColor); - } - } - - @Override - public void onColorSelected(int color) { - this.pickedColor = color; - - super.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE); - } - - ColorPickerPreference getColorPickerPreference() { - return (ColorPickerPreference) getPreference(); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt index 1737f4e0d..82657bbf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt @@ -14,7 +14,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.net.ContentProxySelector import org.thoughtcrime.securesms.util.AppForegroundObserver import org.thoughtcrime.securesms.util.DeviceProperties -import org.thoughtcrime.securesms.util.FeatureFlags import kotlin.time.Duration.Companion.seconds /** diff --git a/app/src/main/res/drawable-v21/webrtc_call_screen_circle_green.xml b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_green.xml index f9261b282..da7afe39f 100644 --- a/app/src/main/res/drawable-v21/webrtc_call_screen_circle_green.xml +++ b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_green.xml @@ -2,7 +2,7 @@ - + diff --git a/app/src/main/res/drawable-v21/webrtc_call_screen_circle_red.xml b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_red.xml index 2e938e6c3..394875aaa 100644 --- a/app/src/main/res/drawable-v21/webrtc_call_screen_circle_red.xml +++ b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_red.xml @@ -2,7 +2,7 @@ - + diff --git a/app/src/main/res/drawable/webrtc_call_screen_circle_green.xml b/app/src/main/res/drawable/webrtc_call_screen_circle_green.xml index c7902555f..10bde79f4 100644 --- a/app/src/main/res/drawable/webrtc_call_screen_circle_green.xml +++ b/app/src/main/res/drawable/webrtc_call_screen_circle_green.xml @@ -7,7 +7,7 @@ - + diff --git a/app/src/main/res/drawable/webrtc_call_screen_circle_red.xml b/app/src/main/res/drawable/webrtc_call_screen_circle_red.xml index 29275bbe0..8c1bdf471 100644 --- a/app/src/main/res/drawable/webrtc_call_screen_circle_red.xml +++ b/app/src/main/res/drawable/webrtc_call_screen_circle_red.xml @@ -7,7 +7,7 @@ - + diff --git a/app/src/main/res/layout/conversation_item_footer_incoming.xml b/app/src/main/res/layout/conversation_item_footer_incoming.xml index 4c6dfb4e4..3870cb194 100644 --- a/app/src/main/res/layout/conversation_item_footer_incoming.xml +++ b/app/src/main/res/layout/conversation_item_footer_incoming.xml @@ -5,7 +5,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - tools:background="@color/green" + tools:background="@color/green_500" tools:layout_width="wrap_content" tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> diff --git a/app/src/main/res/layout/conversation_item_footer_outgoing.xml b/app/src/main/res/layout/conversation_item_footer_outgoing.xml index 825629b0b..47742a351 100644 --- a/app/src/main/res/layout/conversation_item_footer_outgoing.xml +++ b/app/src/main/res/layout/conversation_item_footer_outgoing.xml @@ -7,7 +7,7 @@ android:layout_height="wrap_content" android:gravity="center_vertical|end" android:orientation="horizontal" - tools:background="@color/green" + tools:background="@color/green_500" tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_color.xml b/app/src/main/res/layout/item_color.xml index c0f0a8003..d84f9db24 100644 --- a/app/src/main/res/layout/item_color.xml +++ b/app/src/main/res/layout/item_color.xml @@ -18,6 +18,6 @@ android:layout_height="16dp" android:src="@drawable/circle_white" android:layout_gravity="center" - tools:tint="@color/red"/> + tools:tint="@color/red_500"/> \ No newline at end of file diff --git a/app/src/main/res/layout/media_selection_activity.xml b/app/src/main/res/layout/media_selection_activity.xml index bfaaa89de..4fd6a8ffb 100644 --- a/app/src/main/res/layout/media_selection_activity.xml +++ b/app/src/main/res/layout/media_selection_activity.xml @@ -42,7 +42,7 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:background="@color/red" /> + tools:background="@color/red_500" /> + tools:background="@color/green_500" /> \ No newline at end of file diff --git a/app/src/main/res/layout/media_selection_activity_text_selected_constraints.xml b/app/src/main/res/layout/media_selection_activity_text_selected_constraints.xml index 9dfaf7eb8..691e2bfcf 100644 --- a/app/src/main/res/layout/media_selection_activity_text_selected_constraints.xml +++ b/app/src/main/res/layout/media_selection_activity_text_selected_constraints.xml @@ -33,7 +33,7 @@ app:layout_constraintHorizontal_bias="1" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:background="@color/red" /> + tools:background="@color/red_500" /> + tools:background="@color/green_500" /> diff --git a/app/src/main/res/layout/review_banner_view.xml b/app/src/main/res/layout/review_banner_view.xml index 28e9fef5c..0d6c6f559 100644 --- a/app/src/main/res/layout/review_banner_view.xml +++ b/app/src/main/res/layout/review_banner_view.xml @@ -49,7 +49,7 @@ android:background="@drawable/circle_tintable" android:visibility="gone" app:backgroundTint="?android:windowBackground" - tools:backgroundTint="@color/red" + tools:backgroundTint="@color/red_500" tools:visibility="visible" /> + tools:background="@color/red_500" /> + tools:background="@color/green_500" /> diff --git a/app/src/main/res/layout/stories_text_post_text_entry_content.xml b/app/src/main/res/layout/stories_text_post_text_entry_content.xml index 3a25a8afb..35f82d16c 100644 --- a/app/src/main/res/layout/stories_text_post_text_entry_content.xml +++ b/app/src/main/res/layout/stories_text_post_text_entry_content.xml @@ -106,7 +106,7 @@ app:layout_constraintEnd_toStartOf="@id/color_bar" app:layout_constraintStart_toStartOf="@id/color_bar" tools:alpha="1" - tools:tint="@color/red" /> + tools:tint="@color/red_500" /> + tools:tint="@color/red_500" /> + tools:tint="@color/red_500" /> \ No newline at end of file diff --git a/app/src/main/res/layout/webrtc_call_participant_recycler_item.xml b/app/src/main/res/layout/webrtc_call_participant_recycler_item.xml index 9167c602f..ac9c34fec 100644 --- a/app/src/main/res/layout/webrtc_call_participant_recycler_item.xml +++ b/app/src/main/res/layout/webrtc_call_participant_recycler_item.xml @@ -9,7 +9,7 @@ android:background="@null" android:clipChildren="true" app:cardCornerRadius="8dp" - tools:background="@color/red" + tools:background="@color/red_500" tools:visibility="visible"> + + + + + + + + @@ -78,6 +86,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -86,6 +102,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -232,6 +256,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -319,6 +351,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -327,6 +367,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -504,6 +552,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -549,6 +605,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -767,6 +831,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -775,6 +847,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -826,6 +906,9 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + @@ -1649,6 +1732,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + diff --git a/photoview/build.gradle b/photoview/build.gradle new file mode 100644 index 000000000..010e1ba69 --- /dev/null +++ b/photoview/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'com.android.library' + id 'com.google.protobuf' + id 'kotlin-android' + id 'kotlin-kapt' +} + +android { + buildToolsVersion BUILD_TOOL_VERSION + compileSdkVersion COMPILE_SDK + + defaultConfig { + minSdkVersion MINIMUM_SDK + targetSdkVersion TARGET_SDK + multiDexEnabled true + } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JAVA_VERSION + targetCompatibility JAVA_VERSION + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +protobuf { + protoc { + artifact = 'com.google.protobuf:protoc:3.18.0' + } + generateProtoTasks { + all().each { task -> + task.builtins { + java { + option "lite" + } + } + } + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.4.1' + lintChecks project(':lintchecks') + + coreLibraryDesugaring libs.android.tools.desugar + + api libs.androidx.annotation + + implementation libs.androidx.core.ktx + implementation libs.androidx.lifecycle.common.java8 + implementation libs.google.protobuf.javalite + implementation libs.androidx.sqlite + implementation libs.rxjava3.rxjava + + testImplementation testLibs.junit.junit + testImplementation testLibs.mockito.core + testImplementation (testLibs.robolectric.robolectric) { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } +} diff --git a/photoview/src/main/AndroidManifest.xml b/photoview/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e59843962 --- /dev/null +++ b/photoview/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/Compat.java b/photoview/src/main/java/com/github/chrisbanes/photoview/Compat.java new file mode 100644 index 000000000..d33c31cd1 --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/Compat.java @@ -0,0 +1,39 @@ +/* + Copyright 2011, 2012 Chris Banes. + + 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.github.chrisbanes.photoview; + +import android.annotation.TargetApi; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.view.View; + +class Compat { + + private static final int SIXTY_FPS_INTERVAL = 1000 / 60; + + public static void postOnAnimation(View view, Runnable runnable) { + if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) { + postOnAnimationJellyBean(view, runnable); + } else { + view.postDelayed(runnable, SIXTY_FPS_INTERVAL); + } + } + + @TargetApi(16) + private static void postOnAnimationJellyBean(View view, Runnable runnable) { + view.postOnAnimation(runnable); + } +} diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/CustomGestureDetector.java b/photoview/src/main/java/com/github/chrisbanes/photoview/CustomGestureDetector.java new file mode 100755 index 000000000..aea97a7b2 --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/CustomGestureDetector.java @@ -0,0 +1,214 @@ +/* + Copyright 2011, 2012 Chris Banes. +

+ 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.github.chrisbanes.photoview; + +import android.content.Context; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.VelocityTracker; +import android.view.ViewConfiguration; + +/** + * Does a whole lot of gesture detecting. + */ +class CustomGestureDetector { + + private static final int INVALID_POINTER_ID = -1; + + private int mActivePointerId = INVALID_POINTER_ID; + private int mActivePointerIndex = 0; + private final ScaleGestureDetector mDetector; + + private VelocityTracker mVelocityTracker; + private boolean mIsDragging; + private float mLastTouchX; + private float mLastTouchY; + private final float mTouchSlop; + private final float mMinimumVelocity; + private OnGestureListener mListener; + + CustomGestureDetector(Context context, OnGestureListener listener) { + final ViewConfiguration configuration = ViewConfiguration + .get(context); + mMinimumVelocity = configuration.getScaledMinimumFlingVelocity(); + mTouchSlop = configuration.getScaledTouchSlop(); + + mListener = listener; + ScaleGestureDetector.OnScaleGestureListener mScaleListener = new ScaleGestureDetector.OnScaleGestureListener() { + private float lastFocusX, lastFocusY = 0; + + @Override + public boolean onScale(ScaleGestureDetector detector) { + float scaleFactor = detector.getScaleFactor(); + + if (Float.isNaN(scaleFactor) || Float.isInfinite(scaleFactor)) + return false; + + if (scaleFactor >= 0) { + mListener.onScale(scaleFactor, + detector.getFocusX(), + detector.getFocusY(), + detector.getFocusX() - lastFocusX, + detector.getFocusY() - lastFocusY + ); + lastFocusX = detector.getFocusX(); + lastFocusY = detector.getFocusY(); + } + return true; + } + + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + lastFocusX = detector.getFocusX(); + lastFocusY = detector.getFocusY(); + return true; + } + + @Override + public void onScaleEnd(ScaleGestureDetector detector) { + // NO-OP + } + }; + mDetector = new ScaleGestureDetector(context, mScaleListener); + } + + private float getActiveX(MotionEvent ev) { + try { + return ev.getX(mActivePointerIndex); + } catch (Exception e) { + return ev.getX(); + } + } + + private float getActiveY(MotionEvent ev) { + try { + return ev.getY(mActivePointerIndex); + } catch (Exception e) { + return ev.getY(); + } + } + + public boolean isScaling() { + return mDetector.isInProgress(); + } + + public boolean isDragging() { + return mIsDragging; + } + + public boolean onTouchEvent(MotionEvent ev) { + try { + mDetector.onTouchEvent(ev); + return processTouchEvent(ev); + } catch (IllegalArgumentException e) { + // Fix for support lib bug, happening when onDestroy is called + return true; + } + } + + private boolean processTouchEvent(MotionEvent ev) { + final int action = ev.getAction(); + switch (action & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + mActivePointerId = ev.getPointerId(0); + + mVelocityTracker = VelocityTracker.obtain(); + if (null != mVelocityTracker) { + mVelocityTracker.addMovement(ev); + } + + mLastTouchX = getActiveX(ev); + mLastTouchY = getActiveY(ev); + mIsDragging = false; + break; + case MotionEvent.ACTION_MOVE: + final float x = getActiveX(ev); + final float y = getActiveY(ev); + final float dx = x - mLastTouchX, dy = y - mLastTouchY; + + if (!mIsDragging) { + // Use Pythagoras to see if drag length is larger than + // touch slop + mIsDragging = Math.sqrt((dx * dx) + (dy * dy)) >= mTouchSlop; + } + + if (mIsDragging) { + mListener.onDrag(dx, dy); + mLastTouchX = x; + mLastTouchY = y; + + if (null != mVelocityTracker) { + mVelocityTracker.addMovement(ev); + } + } + break; + case MotionEvent.ACTION_CANCEL: + mActivePointerId = INVALID_POINTER_ID; + // Recycle Velocity Tracker + if (null != mVelocityTracker) { + mVelocityTracker.recycle(); + mVelocityTracker = null; + } + break; + case MotionEvent.ACTION_UP: + mActivePointerId = INVALID_POINTER_ID; + if (mIsDragging) { + if (null != mVelocityTracker) { + mLastTouchX = getActiveX(ev); + mLastTouchY = getActiveY(ev); + + // Compute velocity within the last 1000ms + mVelocityTracker.addMovement(ev); + mVelocityTracker.computeCurrentVelocity(1000); + + final float vX = mVelocityTracker.getXVelocity(), vY = mVelocityTracker + .getYVelocity(); + + // If the velocity is greater than minVelocity, call + // listener + if (Math.max(Math.abs(vX), Math.abs(vY)) >= mMinimumVelocity) { + mListener.onFling(mLastTouchX, mLastTouchY, -vX, + -vY); + } + } + } + + // Recycle Velocity Tracker + if (null != mVelocityTracker) { + mVelocityTracker.recycle(); + mVelocityTracker = null; + } + break; + case MotionEvent.ACTION_POINTER_UP: + final int pointerIndex = Util.getPointerIndex(ev.getAction()); + final int pointerId = ev.getPointerId(pointerIndex); + if (pointerId == mActivePointerId) { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mActivePointerId = ev.getPointerId(newPointerIndex); + mLastTouchX = ev.getX(newPointerIndex); + mLastTouchY = ev.getY(newPointerIndex); + } + break; + } + + mActivePointerIndex = ev + .findPointerIndex(mActivePointerId != INVALID_POINTER_ID ? mActivePointerId + : 0); + return true; + } +} diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/OnGestureListener.java b/photoview/src/main/java/com/github/chrisbanes/photoview/OnGestureListener.java new file mode 100644 index 000000000..37caf68fb --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/OnGestureListener.java @@ -0,0 +1,28 @@ +/* + Copyright 2011, 2012 Chris Banes. + + 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.github.chrisbanes.photoview; + +interface OnGestureListener { + + void onDrag(float dx, float dy); + + void onFling(float startX, float startY, float velocityX, + float velocityY); + + void onScale(float scaleFactor, float focusX, float focusY); + + void onScale(float scaleFactor, float focusX, float focusY, float dx, float dy); +} \ No newline at end of file diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/OnMatrixChangedListener.java b/photoview/src/main/java/com/github/chrisbanes/photoview/OnMatrixChangedListener.java new file mode 100644 index 000000000..f3031d63b --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/OnMatrixChangedListener.java @@ -0,0 +1,18 @@ +package com.github.chrisbanes.photoview; + +import android.graphics.RectF; + +/** + * Interface definition for a callback to be invoked when the internal Matrix has changed for + * this View. + */ +public interface OnMatrixChangedListener { + + /** + * Callback for when the Matrix displaying the Drawable has changed. This could be because + * the View's bounds have changed, or the user has zoomed. + * + * @param rect - Rectangle displaying the Drawable's new bounds. + */ + void onMatrixChanged(RectF rect); +} diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/OnOutsidePhotoTapListener.java b/photoview/src/main/java/com/github/chrisbanes/photoview/OnOutsidePhotoTapListener.java new file mode 100644 index 000000000..99fc7b45a --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/OnOutsidePhotoTapListener.java @@ -0,0 +1,14 @@ +package com.github.chrisbanes.photoview; + +import android.widget.ImageView; + +/** + * Callback when the user tapped outside of the photo + */ +public interface OnOutsidePhotoTapListener { + + /** + * The outside of the photo has been tapped + */ + void onOutsidePhotoTap(ImageView imageView); +} diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/OnPhotoTapListener.java b/photoview/src/main/java/com/github/chrisbanes/photoview/OnPhotoTapListener.java new file mode 100644 index 000000000..5f6f07081 --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/OnPhotoTapListener.java @@ -0,0 +1,22 @@ +package com.github.chrisbanes.photoview; + +import android.widget.ImageView; + +/** + * A callback to be invoked when the Photo is tapped with a single + * tap. + */ +public interface OnPhotoTapListener { + + /** + * A callback to receive where the user taps on a photo. You will only receive a callback if + * the user taps on the actual photo, tapping on 'whitespace' will be ignored. + * + * @param view ImageView the user tapped. + * @param x where the user tapped from the of the Drawable, as percentage of the + * Drawable width. + * @param y where the user tapped from the top of the Drawable, as percentage of the + * Drawable height. + */ + void onPhotoTap(ImageView view, float x, float y); +} diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/OnScaleChangedListener.java b/photoview/src/main/java/com/github/chrisbanes/photoview/OnScaleChangedListener.java new file mode 100644 index 000000000..c6bb7a699 --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/OnScaleChangedListener.java @@ -0,0 +1,17 @@ +package com.github.chrisbanes.photoview; + + +/** + * Interface definition for callback to be invoked when attached ImageView scale changes + */ +public interface OnScaleChangedListener { + + /** + * Callback for when the scale changes + * + * @param scaleFactor the scale factor (less than 1 for zoom out, greater than 1 for zoom in) + * @param focusX focal point X position + * @param focusY focal point Y position + */ + void onScaleChange(float scaleFactor, float focusX, float focusY); +} diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/OnSingleFlingListener.java b/photoview/src/main/java/com/github/chrisbanes/photoview/OnSingleFlingListener.java new file mode 100644 index 000000000..f5dab9254 --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/OnSingleFlingListener.java @@ -0,0 +1,21 @@ +package com.github.chrisbanes.photoview; + +import android.view.MotionEvent; + +/** + * A callback to be invoked when the ImageView is flung with a single + * touch + */ +public interface OnSingleFlingListener { + + /** + * A callback to receive where the user flings on a ImageView. You will receive a callback if + * the user flings anywhere on the view. + * + * @param e1 MotionEvent the user first touch. + * @param e2 MotionEvent the user last touch. + * @param velocityX distance of user's horizontal fling. + * @param velocityY distance of user's vertical fling. + */ + boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY); +} diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/OnViewDragListener.java b/photoview/src/main/java/com/github/chrisbanes/photoview/OnViewDragListener.java new file mode 100644 index 000000000..66999a5e7 --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/OnViewDragListener.java @@ -0,0 +1,16 @@ +package com.github.chrisbanes.photoview; + +/** + * Interface definition for a callback to be invoked when the photo is experiencing a drag event + */ +public interface OnViewDragListener { + + /** + * Callback for when the photo is experiencing a drag event. This cannot be invoked when the + * user is scaling. + * + * @param dx The change of the coordinates in the x-direction + * @param dy The change of the coordinates in the y-direction + */ + void onDrag(float dx, float dy); +} diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/OnViewTapListener.java b/photoview/src/main/java/com/github/chrisbanes/photoview/OnViewTapListener.java new file mode 100644 index 000000000..685625516 --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/OnViewTapListener.java @@ -0,0 +1,16 @@ +package com.github.chrisbanes.photoview; + +import android.view.View; + +public interface OnViewTapListener { + + /** + * A callback to receive where the user taps on a ImageView. You will receive a callback if + * the user taps anywhere on the view, tapping on 'whitespace' will not be ignored. + * + * @param view - View the user tapped. + * @param x - where the user tapped from the left of the View. + * @param y - where the user tapped from the top of the View. + */ + void onViewTap(View view, float x, float y); +} diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/PhotoView.java b/photoview/src/main/java/com/github/chrisbanes/photoview/PhotoView.java new file mode 100644 index 000000000..e94454382 --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/PhotoView.java @@ -0,0 +1,257 @@ +/* + Copyright 2011, 2012 Chris Banes. +

+ 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.github.chrisbanes.photoview; + +import android.content.Context; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.GestureDetector; + +import androidx.appcompat.widget.AppCompatImageView; + + +/** + * A zoomable ImageView. See {@link PhotoViewAttacher} for most of the details on how the zooming + * is accomplished + */ +@SuppressWarnings("unused") +public class PhotoView extends AppCompatImageView { + + private PhotoViewAttacher attacher; + private ScaleType pendingScaleType; + + public PhotoView(Context context) { + this(context, null); + } + + public PhotoView(Context context, AttributeSet attr) { + this(context, attr, 0); + } + + public PhotoView(Context context, AttributeSet attr, int defStyle) { + super(context, attr, defStyle); + init(); + } + + private void init() { + attacher = new PhotoViewAttacher(this); + //We always pose as a Matrix scale type, though we can change to another scale type + //via the attacher + super.setScaleType(ScaleType.MATRIX); + //apply the previously applied scale type + if (pendingScaleType != null) { + setScaleType(pendingScaleType); + pendingScaleType = null; + } + } + + /** + * Get the current {@link PhotoViewAttacher} for this view. Be wary of holding on to references + * to this attacher, as it has a reference to this view, which, if a reference is held in the + * wrong place, can cause memory leaks. + * + * @return the attacher. + */ + public PhotoViewAttacher getAttacher() { + return attacher; + } + + @Override + public ScaleType getScaleType() { + return attacher.getScaleType(); + } + + @Override + public Matrix getImageMatrix() { + return attacher.getImageMatrix(); + } + + @Override + public void setOnLongClickListener(OnLongClickListener l) { + attacher.setOnLongClickListener(l); + } + + @Override + public void setOnClickListener(OnClickListener l) { + attacher.setOnClickListener(l); + } + + @Override + public void setScaleType(ScaleType scaleType) { + if (attacher == null) { + pendingScaleType = scaleType; + } else { + attacher.setScaleType(scaleType); + } + } + + @Override + public void setImageDrawable(Drawable drawable) { + super.setImageDrawable(drawable); + // setImageBitmap calls through to this method + if (attacher != null) { + attacher.update(); + } + } + + @Override + public void setImageResource(int resId) { + super.setImageResource(resId); + if (attacher != null) { + attacher.update(); + } + } + + @Override + public void setImageURI(Uri uri) { + super.setImageURI(uri); + if (attacher != null) { + attacher.update(); + } + } + + @Override + protected boolean setFrame(int l, int t, int r, int b) { + boolean changed = super.setFrame(l, t, r, b); + if (changed) { + attacher.update(); + } + return changed; + } + + public void setRotationTo(float rotationDegree) { + attacher.setRotationTo(rotationDegree); + } + + public void setRotationBy(float rotationDegree) { + attacher.setRotationBy(rotationDegree); + } + + public boolean isZoomable() { + return attacher.isZoomable(); + } + + public void setZoomable(boolean zoomable) { + attacher.setZoomable(zoomable); + } + + public RectF getDisplayRect() { + return attacher.getDisplayRect(); + } + + public void getDisplayMatrix(Matrix matrix) { + attacher.getDisplayMatrix(matrix); + } + + @SuppressWarnings("UnusedReturnValue") public boolean setDisplayMatrix(Matrix finalRectangle) { + return attacher.setDisplayMatrix(finalRectangle); + } + + public void getSuppMatrix(Matrix matrix) { + attacher.getSuppMatrix(matrix); + } + + public boolean setSuppMatrix(Matrix matrix) { + return attacher.setDisplayMatrix(matrix); + } + + public float getMinimumScale() { + return attacher.getMinimumScale(); + } + + public float getMediumScale() { + return attacher.getMediumScale(); + } + + public float getMaximumScale() { + return attacher.getMaximumScale(); + } + + public float getScale() { + return attacher.getScale(); + } + + public void setAllowParentInterceptOnEdge(boolean allow) { + attacher.setAllowParentInterceptOnEdge(allow); + } + + public void setMinimumScale(float minimumScale) { + attacher.setMinimumScale(minimumScale); + } + + public void setMediumScale(float mediumScale) { + attacher.setMediumScale(mediumScale); + } + + public void setMaximumScale(float maximumScale) { + attacher.setMaximumScale(maximumScale); + } + + public void setScaleLevels(float minimumScale, float mediumScale, float maximumScale) { + attacher.setScaleLevels(minimumScale, mediumScale, maximumScale); + } + + public void setOnMatrixChangeListener(OnMatrixChangedListener listener) { + attacher.setOnMatrixChangeListener(listener); + } + + public void setOnPhotoTapListener(OnPhotoTapListener listener) { + attacher.setOnPhotoTapListener(listener); + } + + public void setOnOutsidePhotoTapListener(OnOutsidePhotoTapListener listener) { + attacher.setOnOutsidePhotoTapListener(listener); + } + + public void setOnViewTapListener(OnViewTapListener listener) { + attacher.setOnViewTapListener(listener); + } + + public void setOnViewDragListener(OnViewDragListener listener) { + attacher.setOnViewDragListener(listener); + } + + public void setScale(float scale) { + attacher.setScale(scale); + } + + public void setScale(float scale, boolean animate) { + attacher.setScale(scale, animate); + } + + public void setScale(float scale, float focalX, float focalY, boolean animate) { + attacher.setScale(scale, focalX, focalY, animate); + } + + public void setZoomTransitionDuration(int milliseconds) { + attacher.setZoomTransitionDuration(milliseconds); + } + + public void setOnDoubleTapListener(GestureDetector.OnDoubleTapListener onDoubleTapListener) { + attacher.setOnDoubleTapListener(onDoubleTapListener); + } + + public void setOnScaleChangeListener(OnScaleChangedListener onScaleChangedListener) { + attacher.setOnScaleChangeListener(onScaleChangedListener); + } + + public void setOnSingleFlingListener(OnSingleFlingListener onSingleFlingListener) { + attacher.setOnSingleFlingListener(onSingleFlingListener); + } +} diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/PhotoViewAttacher.java b/photoview/src/main/java/com/github/chrisbanes/photoview/PhotoViewAttacher.java new file mode 100644 index 000000000..55965b81c --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/PhotoViewAttacher.java @@ -0,0 +1,823 @@ +/* + Copyright 2011, 2012 Chris Banes. +

+ 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.github.chrisbanes.photoview; + +import android.content.Context; +import android.graphics.Matrix; +import android.graphics.Matrix.ScaleToFit; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnLongClickListener; +import android.view.ViewParent; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.Interpolator; +import android.widget.ImageView; +import android.widget.ImageView.ScaleType; +import android.widget.OverScroller; + +/** + * The component of {@link PhotoView} which does the work allowing for zooming, scaling, panning, etc. + * It is made public in case you need to subclass something other than AppCompatImageView and still + * gain the functionality that {@link PhotoView} offers + */ +public class PhotoViewAttacher implements View.OnTouchListener, + View.OnLayoutChangeListener { + + private static float DEFAULT_MAX_SCALE = 3.0f; + private static float DEFAULT_MID_SCALE = 1.75f; + private static float DEFAULT_MIN_SCALE = 1.0f; + private static int DEFAULT_ZOOM_DURATION = 200; + + private static final int HORIZONTAL_EDGE_NONE = -1; + private static final int HORIZONTAL_EDGE_LEFT = 0; + private static final int HORIZONTAL_EDGE_RIGHT = 1; + private static final int HORIZONTAL_EDGE_BOTH = 2; + private static final int VERTICAL_EDGE_NONE = -1; + private static final int VERTICAL_EDGE_TOP = 0; + private static final int VERTICAL_EDGE_BOTTOM = 1; + private static final int VERTICAL_EDGE_BOTH = 2; + private static int SINGLE_TOUCH = 1; + + private Interpolator mInterpolator = new AccelerateDecelerateInterpolator(); + private int mZoomDuration = DEFAULT_ZOOM_DURATION; + private float mMinScale = DEFAULT_MIN_SCALE; + private float mMidScale = DEFAULT_MID_SCALE; + private float mMaxScale = DEFAULT_MAX_SCALE; + + private boolean mAllowParentInterceptOnEdge = true; + private boolean mBlockParentIntercept = false; + + private ImageView mImageView; + + // Gesture Detectors + private GestureDetector mGestureDetector; + private CustomGestureDetector mScaleDragDetector; + + // These are set so we don't keep allocating them on the heap + private final Matrix mBaseMatrix = new Matrix(); + private final Matrix mDrawMatrix = new Matrix(); + private final Matrix mSuppMatrix = new Matrix(); + private final RectF mDisplayRect = new RectF(); + private final float[] mMatrixValues = new float[9]; + + // Listeners + private OnMatrixChangedListener mMatrixChangeListener; + private OnPhotoTapListener mPhotoTapListener; + private OnOutsidePhotoTapListener mOutsidePhotoTapListener; + private OnViewTapListener mViewTapListener; + private View.OnClickListener mOnClickListener; + private OnLongClickListener mLongClickListener; + private OnScaleChangedListener mScaleChangeListener; + private OnSingleFlingListener mSingleFlingListener; + private OnViewDragListener mOnViewDragListener; + + private FlingRunnable mCurrentFlingRunnable; + private int mHorizontalScrollEdge = HORIZONTAL_EDGE_BOTH; + private int mVerticalScrollEdge = VERTICAL_EDGE_BOTH; + private float mBaseRotation; + + private boolean mZoomEnabled = true; + private ScaleType mScaleType = ScaleType.FIT_CENTER; + + private OnGestureListener onGestureListener = new OnGestureListener() { + @Override + public void onDrag(float dx, float dy) { + if (mScaleDragDetector.isScaling()) { + return; // Do not drag if we are already scaling + } + if (mOnViewDragListener != null) { + mOnViewDragListener.onDrag(dx, dy); + } + mSuppMatrix.postTranslate(dx, dy); + checkAndDisplayMatrix(); + + /* + * Here we decide whether to let the ImageView's parent to start taking + * over the touch event. + * + * First we check whether this function is enabled. We never want the + * parent to take over if we're scaling. We then check the edge we're + * on, and the direction of the scroll (i.e. if we're pulling against + * the edge, aka 'overscrolling', let the parent take over). + */ + ViewParent parent = mImageView.getParent(); + if (mAllowParentInterceptOnEdge && !mScaleDragDetector.isScaling() && !mBlockParentIntercept) { + if (mHorizontalScrollEdge == HORIZONTAL_EDGE_BOTH + || (mHorizontalScrollEdge == HORIZONTAL_EDGE_LEFT && dx >= 1f) + || (mHorizontalScrollEdge == HORIZONTAL_EDGE_RIGHT && dx <= -1f) + || (mVerticalScrollEdge == VERTICAL_EDGE_TOP && dy >= 1f) + || (mVerticalScrollEdge == VERTICAL_EDGE_BOTTOM && dy <= -1f)) { + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(false); + } + } + } else { + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + } + } + + @Override + public void onFling(float startX, float startY, float velocityX, float velocityY) { + mCurrentFlingRunnable = new FlingRunnable(mImageView.getContext()); + mCurrentFlingRunnable.fling(getImageViewWidth(mImageView), + getImageViewHeight(mImageView), (int) velocityX, (int) velocityY); + mImageView.post(mCurrentFlingRunnable); + } + + @Override + public void onScale(float scaleFactor, float focusX, float focusY) { + onScale(scaleFactor, focusX, focusY, 0, 0); + } + + @Override + public void onScale(float scaleFactor, float focusX, float focusY, float dx, float dy) { + if (getScale() < mMaxScale || scaleFactor < 1f) { + if (mScaleChangeListener != null) { + mScaleChangeListener.onScaleChange(scaleFactor, focusX, focusY); + } + mSuppMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY); + mSuppMatrix.postTranslate(dx, dy); + checkAndDisplayMatrix(); + } + } + }; + + public PhotoViewAttacher(ImageView imageView) { + mImageView = imageView; + imageView.setOnTouchListener(this); + imageView.addOnLayoutChangeListener(this); + if (imageView.isInEditMode()) { + return; + } + mBaseRotation = 0.0f; + // Create Gesture Detectors... + mScaleDragDetector = new CustomGestureDetector(imageView.getContext(), onGestureListener); + mGestureDetector = new GestureDetector(imageView.getContext(), new GestureDetector.SimpleOnGestureListener() { + + // forward long click listener + @Override + public void onLongPress(MotionEvent e) { + if (mLongClickListener != null) { + mLongClickListener.onLongClick(mImageView); + } + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, + float velocityX, float velocityY) { + if (mSingleFlingListener != null) { + if (getScale() > DEFAULT_MIN_SCALE) { + return false; + } + if (e1.getPointerCount() > SINGLE_TOUCH + || e2.getPointerCount() > SINGLE_TOUCH) { + return false; + } + return mSingleFlingListener.onFling(e1, e2, velocityX, velocityY); + } + return false; + } + }); + mGestureDetector.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() { + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + if (mOnClickListener != null) { + mOnClickListener.onClick(mImageView); + } + final RectF displayRect = getDisplayRect(); + final float x = e.getX(), y = e.getY(); + if (mViewTapListener != null) { + mViewTapListener.onViewTap(mImageView, x, y); + } + if (displayRect != null) { + // Check to see if the user tapped on the photo + if (displayRect.contains(x, y)) { + float xResult = (x - displayRect.left) + / displayRect.width(); + float yResult = (y - displayRect.top) + / displayRect.height(); + if (mPhotoTapListener != null) { + mPhotoTapListener.onPhotoTap(mImageView, xResult, yResult); + } + return true; + } else { + if (mOutsidePhotoTapListener != null) { + mOutsidePhotoTapListener.onOutsidePhotoTap(mImageView); + } + } + } + return false; + } + + @Override + public boolean onDoubleTap(MotionEvent ev) { + try { + float scale = getScale(); + float x = ev.getX(); + float y = ev.getY(); + if (scale < getMediumScale()) { + setScale(getMediumScale(), x, y, true); + } else if (scale >= getMediumScale() && scale < getMaximumScale()) { + setScale(getMaximumScale(), x, y, true); + } else { + setScale(getMinimumScale(), x, y, true); + } + } catch (ArrayIndexOutOfBoundsException e) { + // Can sometimes happen when getX() and getY() is called + } + return true; + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + // Wait for the confirmed onDoubleTap() instead + return false; + } + }); + } + + public void setOnDoubleTapListener(GestureDetector.OnDoubleTapListener newOnDoubleTapListener) { + this.mGestureDetector.setOnDoubleTapListener(newOnDoubleTapListener); + } + + public void setOnScaleChangeListener(OnScaleChangedListener onScaleChangeListener) { + this.mScaleChangeListener = onScaleChangeListener; + } + + public void setOnSingleFlingListener(OnSingleFlingListener onSingleFlingListener) { + this.mSingleFlingListener = onSingleFlingListener; + } + + @Deprecated + public boolean isZoomEnabled() { + return mZoomEnabled; + } + + public RectF getDisplayRect() { + checkMatrixBounds(); + return getDisplayRect(getDrawMatrix()); + } + + public boolean setDisplayMatrix(Matrix finalMatrix) { + if (finalMatrix == null) { + throw new IllegalArgumentException("Matrix cannot be null"); + } + if (mImageView.getDrawable() == null) { + return false; + } + mSuppMatrix.set(finalMatrix); + checkAndDisplayMatrix(); + return true; + } + + public void setBaseRotation(final float degrees) { + mBaseRotation = degrees % 360; + update(); + setRotationBy(mBaseRotation); + checkAndDisplayMatrix(); + } + + public void setRotationTo(float degrees) { + mSuppMatrix.setRotate(degrees % 360); + checkAndDisplayMatrix(); + } + + public void setRotationBy(float degrees) { + mSuppMatrix.postRotate(degrees % 360); + checkAndDisplayMatrix(); + } + + public float getMinimumScale() { + return mMinScale; + } + + public float getMediumScale() { + return mMidScale; + } + + public float getMaximumScale() { + return mMaxScale; + } + + public float getScale() { + return (float) Math.sqrt((float) Math.pow(getValue(mSuppMatrix, Matrix.MSCALE_X), 2) + (float) Math.pow + (getValue(mSuppMatrix, Matrix.MSKEW_Y), 2)); + } + + public ScaleType getScaleType() { + return mScaleType; + } + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int + oldRight, int oldBottom) { + // Update our base matrix, as the bounds have changed + if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) { + updateBaseMatrix(mImageView.getDrawable()); + } + } + + @Override + public boolean onTouch(View v, MotionEvent ev) { + boolean handled = false; + if (mZoomEnabled && Util.hasDrawable((ImageView) v)) { + switch (ev.getAction()) { + case MotionEvent.ACTION_DOWN: + ViewParent parent = v.getParent(); + // First, disable the Parent from intercepting the touch + // event + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + // If we're flinging, and the user presses down, cancel + // fling + cancelFling(); + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + // If the user has zoomed less than min scale, zoom back + // to min scale + if (getScale() < mMinScale) { + RectF rect = getDisplayRect(); + if (rect != null) { + v.post(new AnimatedZoomRunnable(getScale(), mMinScale, + rect.centerX(), rect.centerY())); + handled = true; + } + } else if (getScale() > mMaxScale) { + RectF rect = getDisplayRect(); + if (rect != null) { + v.post(new AnimatedZoomRunnable(getScale(), mMaxScale, + rect.centerX(), rect.centerY())); + handled = true; + } + } + break; + } + // Try the Scale/Drag detector + if (mScaleDragDetector != null) { + boolean wasScaling = mScaleDragDetector.isScaling(); + boolean wasDragging = mScaleDragDetector.isDragging(); + handled = mScaleDragDetector.onTouchEvent(ev); + boolean didntScale = !wasScaling && !mScaleDragDetector.isScaling(); + boolean didntDrag = !wasDragging && !mScaleDragDetector.isDragging(); + mBlockParentIntercept = didntScale && didntDrag; + } + // Check to see if the user double tapped + if (mGestureDetector != null && mGestureDetector.onTouchEvent(ev)) { + handled = true; + } + + } + return handled; + } + + public void setAllowParentInterceptOnEdge(boolean allow) { + mAllowParentInterceptOnEdge = allow; + } + + public void setMinimumScale(float minimumScale) { + Util.checkZoomLevels(minimumScale, mMidScale, mMaxScale); + mMinScale = minimumScale; + } + + public void setMediumScale(float mediumScale) { + Util.checkZoomLevels(mMinScale, mediumScale, mMaxScale); + mMidScale = mediumScale; + } + + public void setMaximumScale(float maximumScale) { + Util.checkZoomLevels(mMinScale, mMidScale, maximumScale); + mMaxScale = maximumScale; + } + + public void setScaleLevels(float minimumScale, float mediumScale, float maximumScale) { + Util.checkZoomLevels(minimumScale, mediumScale, maximumScale); + mMinScale = minimumScale; + mMidScale = mediumScale; + mMaxScale = maximumScale; + } + + public void setOnLongClickListener(OnLongClickListener listener) { + mLongClickListener = listener; + } + + public void setOnClickListener(View.OnClickListener listener) { + mOnClickListener = listener; + } + + public void setOnMatrixChangeListener(OnMatrixChangedListener listener) { + mMatrixChangeListener = listener; + } + + public void setOnPhotoTapListener(OnPhotoTapListener listener) { + mPhotoTapListener = listener; + } + + public void setOnOutsidePhotoTapListener(OnOutsidePhotoTapListener mOutsidePhotoTapListener) { + this.mOutsidePhotoTapListener = mOutsidePhotoTapListener; + } + + public void setOnViewTapListener(OnViewTapListener listener) { + mViewTapListener = listener; + } + + public void setOnViewDragListener(OnViewDragListener listener) { + mOnViewDragListener = listener; + } + + public void setScale(float scale) { + setScale(scale, false); + } + + public void setScale(float scale, boolean animate) { + setScale(scale, + (mImageView.getRight()) / 2, + (mImageView.getBottom()) / 2, + animate); + } + + public void setScale(float scale, float focalX, float focalY, + boolean animate) { + // Check to see if the scale is within bounds + if (scale < mMinScale || scale > mMaxScale) { + throw new IllegalArgumentException("Scale must be within the range of minScale and maxScale"); + } + if (animate) { + mImageView.post(new AnimatedZoomRunnable(getScale(), scale, + focalX, focalY)); + } else { + mSuppMatrix.setScale(scale, scale, focalX, focalY); + checkAndDisplayMatrix(); + } + } + + /** + * Set the zoom interpolator + * + * @param interpolator the zoom interpolator + */ + public void setZoomInterpolator(Interpolator interpolator) { + mInterpolator = interpolator; + } + + public void setScaleType(ScaleType scaleType) { + if (Util.isSupportedScaleType(scaleType) && scaleType != mScaleType) { + mScaleType = scaleType; + update(); + } + } + + public boolean isZoomable() { + return mZoomEnabled; + } + + public void setZoomable(boolean zoomable) { + mZoomEnabled = zoomable; + update(); + } + + public void update() { + if (mZoomEnabled) { + // Update the base matrix using the current drawable + updateBaseMatrix(mImageView.getDrawable()); + } else { + // Reset the Matrix... + resetMatrix(); + } + } + + /** + * Get the display matrix + * + * @param matrix target matrix to copy to + */ + public void getDisplayMatrix(Matrix matrix) { + matrix.set(getDrawMatrix()); + } + + /** + * Get the current support matrix + */ + public void getSuppMatrix(Matrix matrix) { + matrix.set(mSuppMatrix); + } + + private Matrix getDrawMatrix() { + mDrawMatrix.set(mBaseMatrix); + mDrawMatrix.postConcat(mSuppMatrix); + return mDrawMatrix; + } + + public Matrix getImageMatrix() { + return mDrawMatrix; + } + + public void setZoomTransitionDuration(int milliseconds) { + this.mZoomDuration = milliseconds; + } + + /** + * Helper method that 'unpacks' a Matrix and returns the required value + * + * @param matrix Matrix to unpack + * @param whichValue Which value from Matrix.M* to return + * @return returned value + */ + private float getValue(Matrix matrix, int whichValue) { + matrix.getValues(mMatrixValues); + return mMatrixValues[whichValue]; + } + + /** + * Resets the Matrix back to FIT_CENTER, and then displays its contents + */ + private void resetMatrix() { + mSuppMatrix.reset(); + setRotationBy(mBaseRotation); + setImageViewMatrix(getDrawMatrix()); + checkMatrixBounds(); + } + + private void setImageViewMatrix(Matrix matrix) { + mImageView.setImageMatrix(matrix); + // Call MatrixChangedListener if needed + if (mMatrixChangeListener != null) { + RectF displayRect = getDisplayRect(matrix); + if (displayRect != null) { + mMatrixChangeListener.onMatrixChanged(displayRect); + } + } + } + + /** + * Helper method that simply checks the Matrix, and then displays the result + */ + private void checkAndDisplayMatrix() { + if (checkMatrixBounds()) { + setImageViewMatrix(getDrawMatrix()); + } + } + + /** + * Helper method that maps the supplied Matrix to the current Drawable + * + * @param matrix - Matrix to map Drawable against + * @return RectF - Displayed Rectangle + */ + private RectF getDisplayRect(Matrix matrix) { + Drawable d = mImageView.getDrawable(); + if (d != null) { + mDisplayRect.set(0, 0, d.getIntrinsicWidth(), + d.getIntrinsicHeight()); + matrix.mapRect(mDisplayRect); + return mDisplayRect; + } + return null; + } + + /** + * Calculate Matrix for FIT_CENTER + * + * @param drawable - Drawable being displayed + */ + private void updateBaseMatrix(Drawable drawable) { + if (drawable == null) { + return; + } + final float viewWidth = getImageViewWidth(mImageView); + final float viewHeight = getImageViewHeight(mImageView); + final int drawableWidth = drawable.getIntrinsicWidth(); + final int drawableHeight = drawable.getIntrinsicHeight(); + mBaseMatrix.reset(); + final float widthScale = viewWidth / drawableWidth; + final float heightScale = viewHeight / drawableHeight; + if (mScaleType == ScaleType.CENTER) { + mBaseMatrix.postTranslate((viewWidth - drawableWidth) / 2F, + (viewHeight - drawableHeight) / 2F); + + } else if (mScaleType == ScaleType.CENTER_CROP) { + float scale = Math.max(widthScale, heightScale); + mBaseMatrix.postScale(scale, scale); + mBaseMatrix.postTranslate((viewWidth - drawableWidth * scale) / 2F, + (viewHeight - drawableHeight * scale) / 2F); + + } else if (mScaleType == ScaleType.CENTER_INSIDE) { + float scale = Math.min(1.0f, Math.min(widthScale, heightScale)); + mBaseMatrix.postScale(scale, scale); + mBaseMatrix.postTranslate((viewWidth - drawableWidth * scale) / 2F, + (viewHeight - drawableHeight * scale) / 2F); + + } else { + RectF mTempSrc = new RectF(0, 0, drawableWidth, drawableHeight); + RectF mTempDst = new RectF(0, 0, viewWidth, viewHeight); + if ((int) mBaseRotation % 180 != 0) { + mTempSrc = new RectF(0, 0, drawableHeight, drawableWidth); + } + switch (mScaleType) { + case FIT_CENTER: + mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.CENTER); + break; + case FIT_START: + mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.START); + break; + case FIT_END: + mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.END); + break; + case FIT_XY: + mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.FILL); + break; + default: + break; + } + } + resetMatrix(); + } + + private boolean checkMatrixBounds() { + final RectF rect = getDisplayRect(getDrawMatrix()); + if (rect == null) { + return false; + } + final float height = rect.height(), width = rect.width(); + float deltaX = 0, deltaY = 0; + final int viewHeight = getImageViewHeight(mImageView); + if (height <= viewHeight) { + switch (mScaleType) { + case FIT_START: + deltaY = -rect.top; + break; + case FIT_END: + deltaY = viewHeight - height - rect.top; + break; + default: + deltaY = (viewHeight - height) / 2 - rect.top; + break; + } + mVerticalScrollEdge = VERTICAL_EDGE_BOTH; + } else if (rect.top > 0) { + mVerticalScrollEdge = VERTICAL_EDGE_TOP; + deltaY = -rect.top; + } else if (rect.bottom < viewHeight) { + mVerticalScrollEdge = VERTICAL_EDGE_BOTTOM; + deltaY = viewHeight - rect.bottom; + } else { + mVerticalScrollEdge = VERTICAL_EDGE_NONE; + } + final int viewWidth = getImageViewWidth(mImageView); + if (width <= viewWidth) { + switch (mScaleType) { + case FIT_START: + deltaX = -rect.left; + break; + case FIT_END: + deltaX = viewWidth - width - rect.left; + break; + default: + deltaX = (viewWidth - width) / 2 - rect.left; + break; + } + mHorizontalScrollEdge = HORIZONTAL_EDGE_BOTH; + } else if (rect.left > 0) { + mHorizontalScrollEdge = HORIZONTAL_EDGE_LEFT; + deltaX = -rect.left; + } else if (rect.right < viewWidth) { + deltaX = viewWidth - rect.right; + mHorizontalScrollEdge = HORIZONTAL_EDGE_RIGHT; + } else { + mHorizontalScrollEdge = HORIZONTAL_EDGE_NONE; + } + // Finally actually translate the matrix + mSuppMatrix.postTranslate(deltaX, deltaY); + return true; + } + + private int getImageViewWidth(ImageView imageView) { + return imageView.getWidth() - imageView.getPaddingLeft() - imageView.getPaddingRight(); + } + + private int getImageViewHeight(ImageView imageView) { + return imageView.getHeight() - imageView.getPaddingTop() - imageView.getPaddingBottom(); + } + + private void cancelFling() { + if (mCurrentFlingRunnable != null) { + mCurrentFlingRunnable.cancelFling(); + mCurrentFlingRunnable = null; + } + } + + private class AnimatedZoomRunnable implements Runnable { + + private final float mFocalX, mFocalY; + private final long mStartTime; + private final float mZoomStart, mZoomEnd; + + public AnimatedZoomRunnable(final float currentZoom, final float targetZoom, + final float focalX, final float focalY) { + mFocalX = focalX; + mFocalY = focalY; + mStartTime = System.currentTimeMillis(); + mZoomStart = currentZoom; + mZoomEnd = targetZoom; + } + + @Override + public void run() { + float t = interpolate(); + float scale = mZoomStart + t * (mZoomEnd - mZoomStart); + float deltaScale = scale / getScale(); + onGestureListener.onScale(deltaScale, mFocalX, mFocalY); + // We haven't hit our target scale yet, so post ourselves again + if (t < 1f) { + Compat.postOnAnimation(mImageView, this); + } + } + + private float interpolate() { + float t = 1f * (System.currentTimeMillis() - mStartTime) / mZoomDuration; + t = Math.min(1f, t); + t = mInterpolator.getInterpolation(t); + return t; + } + } + + private class FlingRunnable implements Runnable { + + private final OverScroller mScroller; + private int mCurrentX, mCurrentY; + + public FlingRunnable(Context context) { + mScroller = new OverScroller(context); + } + + public void cancelFling() { + mScroller.forceFinished(true); + } + + public void fling(int viewWidth, int viewHeight, int velocityX, + int velocityY) { + final RectF rect = getDisplayRect(); + if (rect == null) { + return; + } + final int startX = Math.round(-rect.left); + final int minX, maxX, minY, maxY; + if (viewWidth < rect.width()) { + minX = 0; + maxX = Math.round(rect.width() - viewWidth); + } else { + minX = maxX = startX; + } + final int startY = Math.round(-rect.top); + if (viewHeight < rect.height()) { + minY = 0; + maxY = Math.round(rect.height() - viewHeight); + } else { + minY = maxY = startY; + } + mCurrentX = startX; + mCurrentY = startY; + // If we actually can move, fling the scroller + if (startX != maxX || startY != maxY) { + mScroller.fling(startX, startY, velocityX, velocityY, minX, + maxX, minY, maxY, 0, 0); + } + } + + @Override + public void run() { + if (mScroller.isFinished()) { + return; // remaining post that should not be handled + } + if (mScroller.computeScrollOffset()) { + final int newX = mScroller.getCurrX(); + final int newY = mScroller.getCurrY(); + mSuppMatrix.postTranslate(mCurrentX - newX, mCurrentY - newY); + checkAndDisplayMatrix(); + mCurrentX = newX; + mCurrentY = newY; + // Post On animation + Compat.postOnAnimation(mImageView, this); + } + } + } +} diff --git a/photoview/src/main/java/com/github/chrisbanes/photoview/Util.java b/photoview/src/main/java/com/github/chrisbanes/photoview/Util.java new file mode 100644 index 000000000..2e3e5adac --- /dev/null +++ b/photoview/src/main/java/com/github/chrisbanes/photoview/Util.java @@ -0,0 +1,37 @@ +package com.github.chrisbanes.photoview; + +import android.view.MotionEvent; +import android.widget.ImageView; + +class Util { + + static void checkZoomLevels(float minZoom, float midZoom, + float maxZoom) { + if (minZoom >= midZoom) { + throw new IllegalArgumentException( + "Minimum zoom has to be less than Medium zoom. Call setMinimumZoom() with a more appropriate value"); + } else if (midZoom >= maxZoom) { + throw new IllegalArgumentException( + "Medium zoom has to be less than Maximum zoom. Call setMaximumZoom() with a more appropriate value"); + } + } + + static boolean hasDrawable(ImageView imageView) { + return imageView.getDrawable() != null; + } + + static boolean isSupportedScaleType(final ImageView.ScaleType scaleType) { + if (scaleType == null) { + return false; + } + switch (scaleType) { + case MATRIX: + throw new IllegalStateException("Matrix scale type is not supported"); + } + return true; + } + + static int getPointerIndex(int action) { + return (action & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT; + } +} diff --git a/settings.gradle b/settings.gradle index e6ad5e1c3..3abff5bf8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -22,6 +22,8 @@ include ':contacts' include ':contacts-app' include ':qr' include ':qr-app' +include ':sticky-header-grid' +include ':photoview' project(':app').name = 'Signal-Android' project(':paging').projectDir = file('paging/lib') diff --git a/sticky-header-grid/README.md b/sticky-header-grid/README.md new file mode 100644 index 000000000..444981959 --- /dev/null +++ b/sticky-header-grid/README.md @@ -0,0 +1,2 @@ +This was migrated from [Codewaves/Sticky-Header-Grid](https://github.com/Codewaves/Sticky-Header-Grid), because the artifact is still on published on jcenter. +The project was small enough that it was easier to just bring the two files in as a source dependency. \ No newline at end of file diff --git a/sticky-header-grid/build.gradle b/sticky-header-grid/build.gradle new file mode 100644 index 000000000..8c5183c68 --- /dev/null +++ b/sticky-header-grid/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'com.android.library' + id 'com.google.protobuf' + id 'kotlin-android' + id 'kotlin-kapt' +} + +android { + buildToolsVersion BUILD_TOOL_VERSION + compileSdkVersion COMPILE_SDK + + defaultConfig { + minSdkVersion MINIMUM_SDK + targetSdkVersion TARGET_SDK + multiDexEnabled true + } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JAVA_VERSION + targetCompatibility JAVA_VERSION + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +protobuf { + protoc { + artifact = 'com.google.protobuf:protoc:3.18.0' + } + generateProtoTasks { + all().each { task -> + task.builtins { + java { + option "lite" + } + } + } + } +} + +dependencies { + implementation 'androidx.recyclerview:recyclerview:1.2.1' + lintChecks project(':lintchecks') + + coreLibraryDesugaring libs.android.tools.desugar + + api libs.androidx.annotation + + implementation libs.androidx.core.ktx + implementation libs.androidx.lifecycle.common.java8 + implementation libs.google.protobuf.javalite + implementation libs.androidx.sqlite + implementation libs.rxjava3.rxjava + + testImplementation testLibs.junit.junit + testImplementation testLibs.mockito.core + testImplementation (testLibs.robolectric.robolectric) { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } +} diff --git a/sticky-header-grid/src/main/AndroidManifest.xml b/sticky-header-grid/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e2c2c148e --- /dev/null +++ b/sticky-header-grid/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridAdapter.java b/sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridAdapter.java new file mode 100644 index 000000000..d2a0a7e21 --- /dev/null +++ b/sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridAdapter.java @@ -0,0 +1,613 @@ +package com.codewaves.stickyheadergrid; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.security.InvalidParameterException; +import java.util.ArrayList; + +import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; + + +/** + * Created by Sergej Kravcenko on 4/24/2017. + * Copyright (c) 2017 Sergej Kravcenko + */ + +@SuppressWarnings({"unused", "WeakerAccess"}) +public abstract class StickyHeaderGridAdapter extends RecyclerView.Adapter { + public static final String TAG = "StickyHeaderGridAdapter"; + + public static final int TYPE_HEADER = 0; + public static final int TYPE_ITEM = 1; + + private ArrayList

mSections; + private int[] mSectionIndices; + private int mTotalItemNumber; + + @SuppressWarnings("WeakerAccess") + public static class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(View itemView) { + super(itemView); + } + + public boolean isHeader() { + return false; + } + + + public int getSectionItemViewType() { + return StickyHeaderGridAdapter.externalViewType(getItemViewType()); + } + } + + public static class ItemViewHolder extends ViewHolder { + public ItemViewHolder(View itemView) { + super(itemView); + } + } + + public static class HeaderViewHolder extends ViewHolder { + public HeaderViewHolder(View itemView) { + super(itemView); + } + + @Override + public boolean isHeader() { + return true; + } + } + + private static class Section { + private int position; + private int itemNumber; + private int length; + } + + private void calculateSections() { + mSections = new ArrayList<>(); + + int total = 0; + int sectionCount = getSectionCount(); + for (int s = 0; s < sectionCount; s++) { + final Section section = new Section(); + section.position = total; + section.itemNumber = getSectionItemCount(s); + section.length = section.itemNumber + 1; + mSections.add(section); + + total += section.length; + } + mTotalItemNumber = total; + + total = 0; + mSectionIndices = new int[mTotalItemNumber]; + for (int s = 0; s < sectionCount; s++) { + final Section section = mSections.get(s); + for (int i = 0; i < section.length; i++) { + mSectionIndices[total + i] = s; + } + total += section.length; + } + } + + protected int getItemViewInternalType(int position) { + final int section = getAdapterPositionSection(position); + final Section sectionObject = mSections.get(section); + final int sectionPosition = position - sectionObject.position; + + return getItemViewInternalType(section, sectionPosition); + } + + private int getItemViewInternalType(int section, int position) { + return position == 0 ? TYPE_HEADER : TYPE_ITEM; + } + + static private int internalViewType(int type) { + return type & 0xFF; + } + + static private int externalViewType(int type) { + return type >> 8; + } + + @Override + final public int getItemCount() { + if (mSections == null) { + calculateSections(); + } + return mTotalItemNumber; + } + + @NonNull + @Override + final public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final int internalType = internalViewType(viewType); + final int externalType = externalViewType(viewType); + + switch (internalType) { + case TYPE_HEADER: + return onCreateHeaderViewHolder(parent, externalType); + case TYPE_ITEM: + return onCreateItemViewHolder(parent, externalType); + default: + throw new InvalidParameterException("Invalid viewType: " + viewType); + } + } + + @Override + final public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + if (mSections == null) { + calculateSections(); + } + + final int section = mSectionIndices[position]; + final int internalType = internalViewType(holder.getItemViewType()); + final int externalType = externalViewType(holder.getItemViewType()); + + switch (internalType) { + case TYPE_HEADER: + onBindHeaderViewHolder((HeaderViewHolder)holder, section); + break; + case TYPE_ITEM: + final ItemViewHolder itemHolder = (ItemViewHolder)holder; + final int offset = getItemSectionOffset(section, position); + onBindItemViewHolder((ItemViewHolder)holder, section, offset); + break; + default: + throw new InvalidParameterException("invalid viewType: " + internalType); + } + } + + @Override + final public int getItemViewType(int position) { + final int section = getAdapterPositionSection(position); + final Section sectionObject = mSections.get(section); + final int sectionPosition = position - sectionObject.position; + final int internalType = getItemViewInternalType(section, sectionPosition); + int externalType = 0; + + switch (internalType) { + case TYPE_HEADER: + externalType = getSectionHeaderViewType(section); + break; + case TYPE_ITEM: + externalType = getSectionItemViewType(section, sectionPosition - 1); + break; + } + + return ((externalType & 0xFF) << 8) | (internalType & 0xFF); + } + + // Helpers + private int getItemSectionHeaderPosition(int position) { + return getSectionHeaderPosition(getAdapterPositionSection(position)); + } + + private int getAdapterPosition(int section, int offset) { + if (mSections == null) { + calculateSections(); + } + + if (section < 0) { + throw new IndexOutOfBoundsException("section " + section + " < 0"); + } + + if (section >= mSections.size()) { + throw new IndexOutOfBoundsException("section " + section + " >=" + mSections.size()); + } + + final Section sectionObject = mSections.get(section); + return sectionObject.position + offset; + } + + /** + * Given a section and an adapter position get the offset of an item + * inside section. + * + * @param section section to query + * @param position adapter position + * @return The item offset inside the section. + */ + public int getItemSectionOffset(int section, int position) { + if (mSections == null) { + calculateSections(); + } + + if (section < 0) { + throw new IndexOutOfBoundsException("section " + section + " < 0"); + } + + if (section >= mSections.size()) { + throw new IndexOutOfBoundsException("section " + section + " >=" + mSections.size()); + } + + final Section sectionObject = mSections.get(section); + final int localPosition = position - sectionObject.position; + if (localPosition >= sectionObject.length) { + throw new IndexOutOfBoundsException("localPosition: " + localPosition + " >=" + sectionObject.length); + } + + return localPosition - 1; + } + + /** + * Returns the section index having item or header with provided + * provider position. + * + * @param position adapter position + * @return The section containing provided adapter position. + */ + public int getAdapterPositionSection(int position) { + if (mSections == null) { + calculateSections(); + } + + if (getItemCount() == 0) { + return NO_POSITION; + } + + if (position < 0) { + throw new IndexOutOfBoundsException("position " + position + " < 0"); + } + + if (position >= getItemCount()) { + throw new IndexOutOfBoundsException("position " + position + " >=" + getItemCount()); + } + + return mSectionIndices[position]; + } + + /** + * Returns the adapter position for given section header. Use + * this only for {@link RecyclerView#scrollToPosition(int)} or similar functions. + * Never directly manipulate adapter items using this position. + * + * @param section section to query + * @return The adapter position. + */ + public int getSectionHeaderPosition(int section) { + return getAdapterPosition(section, 0); + } + + /** + * Returns the adapter position for given section and + * offset. Use this only for {@link RecyclerView#scrollToPosition(int)} + * or similar functions. Never directly manipulate adapter items using this position. + * + * @param section section to query + * @param position item position inside the section + * @return The adapter position. + */ + public int getSectionItemPosition(int section, int position) { + return getAdapterPosition(section, position + 1); + } + + // Overrides + /** + * Returns the total number of sections in the data set held by the adapter. + * + * @return The total number of section in this adapter. + */ + public int getSectionCount() { + return 0; + } + + /** + * Returns the number of items in the section. + * + * @param section section to query + * @return The total number of items in the section. + */ + public int getSectionItemCount(int section) { + return 0; + } + + /** + * Return the view type of the section header for the purposes + * of view recycling. + * + *

The default implementation of this method returns 0, making the assumption of + * a single view type for the headers. Unlike ListView adapters, types need not + * be contiguous. Consider using id resources to uniquely identify item view types. + * + * @param section section to query + * @return integer value identifying the type of the view needed to represent the header in + * section. Type codes need not be contiguous. + */ + public int getSectionHeaderViewType(int section) { + return 0; + } + + /** + * Return the view type of the item at position in section for + * the purposes of view recycling. + * + *

The default implementation of this method returns 0, making the assumption of + * a single view type for the adapter. Unlike ListView adapters, types need not + * be contiguous. Consider using id resources to uniquely identify item view types. + * + * @param section section to query + * @param offset section position to query + * @return integer value identifying the type of the view needed to represent the item at + * position in section. Type codes need not be + * contiguous. + */ + public int getSectionItemViewType(int section, int offset) { + return 0; + } + + /** + * Returns true if header in section is sticky. + * + * @param section section to query + * @return true if section header is sticky. + */ + public boolean isSectionHeaderSticky(int section) { + return true; + } + + /** + * Called when RecyclerView needs a new {@link HeaderViewHolder} of the given type to represent + * a header. + *

+ * This new HeaderViewHolder should be constructed with a new View that can represent the headers + * of the given type. You can either create a new View manually or inflate it from an XML + * layout file. + *

+ * The new HeaderViewHolder will be used to display items of the adapter using + * {@link #onBindHeaderViewHolder(HeaderViewHolder, int)}. Since it will be re-used to display + * different items in the data set, it is a good idea to cache references to sub views of + * the View to avoid unnecessary {@link View#findViewById(int)} calls. + * + * @param parent The ViewGroup into which the new View will be added after it is bound to + * an adapter position. + * @param headerType The view type of the new View. + * + * @return A new ViewHolder that holds a View of the given view type. + * @see #getSectionHeaderViewType(int) + * @see #onBindHeaderViewHolder(HeaderViewHolder, int) + */ + public abstract HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType); + + /** + * Called when RecyclerView needs a new {@link ItemViewHolder} of the given type to represent + * an item. + *

+ * This new ViewHolder should be constructed with a new View that can represent the items + * of the given type. You can either create a new View manually or inflate it from an XML + * layout file. + *

+ * The new ViewHolder will be used to display items of the adapter using + * {@link #onBindItemViewHolder(ItemViewHolder, int, int)}. Since it will be re-used to display + * different items in the data set, it is a good idea to cache references to sub views of + * the View to avoid unnecessary {@link View#findViewById(int)} calls. + * + * @param parent The ViewGroup into which the new View will be added after it is bound to + * an adapter position. + * @param itemType The view type of the new View. + * + * @return A new ViewHolder that holds a View of the given view type. + * @see #getSectionItemViewType(int, int) + * @see #onBindItemViewHolder(ItemViewHolder, int, int) + */ + public abstract ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType); + + /** + * Called by RecyclerView to display the data at the specified position. This method should + * update the contents of the {@link HeaderViewHolder#itemView} to reflect the header at the given + * position. + *

+ * Note that unlike {@link android.widget.ListView}, RecyclerView will not call this method + * again if the position of the header changes in the data set unless the header itself is + * invalidated or the new position cannot be determined. For this reason, you should only + * use the section parameter while acquiring the + * related header data inside this method and should not keep a copy of it. If you need the + * position of a header later on (e.g. in a click listener), use + * {@link HeaderViewHolder#getAdapterPosition()} which will have the updated adapter + * position. Then you can use {@link #getAdapterPositionSection(int)} to get section index. + * + * + * @param viewHolder The ViewHolder which should be updated to represent the contents of the + * header at the given position in the data set. + * @param section The index of the section. + */ + public abstract void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int section); + + /** + * Called by RecyclerView to display the data at the specified position. This method should + * update the contents of the {@link ItemViewHolder#itemView} to reflect the item at the given + * position. + *

+ * Note that unlike {@link android.widget.ListView}, RecyclerView will not call this method + * again if the position of the item changes in the data set unless the item itself is + * invalidated or the new position cannot be determined. For this reason, you should only + * use the offset and section parameters while acquiring the + * related data item inside this method and should not keep a copy of it. If you need the + * position of an item later on (e.g. in a click listener), use + * {@link ItemViewHolder#getAdapterPosition()} which will have the updated adapter + * position. Then you can use {@link #getAdapterPositionSection(int)} and + * {@link #getItemSectionOffset(int, int)} + * + * + * @param viewHolder The ViewHolder which should be updated to represent the contents of the + * item at the given position in the data set. + * @param section The index of the section. + * @param offset The position of the item within the section. + */ + public abstract void onBindItemViewHolder(ItemViewHolder viewHolder, int section, int offset); + + // Notify + /** + * Notify any registered observers that the data set has changed. + * + *

There are two different classes of data change events, item changes and structural + * changes. Item changes are when a single item has its data updated but no positional + * changes have occurred. Structural changes are when items are inserted, removed or moved + * within the data set.

+ * + *

This event does not specify what about the data set has changed, forcing + * any observers to assume that all existing items and structure may no longer be valid. + * LayoutManagers will be forced to fully rebind and relayout all visible views.

+ * + *

RecyclerView will attempt to synthesize visible structural change events + * for adapters that report that they have {@link #hasStableIds() stable IDs} when + * this method is used. This can help for the purposes of animation and visual + * object persistence but individual item views will still need to be rebound + * and relaid out.

+ * + *

If you are writing an adapter it will always be more efficient to use the more + * specific change events if you can. Rely on notifyDataSetChanged() + * as a last resort.

+ * + * @see #notifySectionDataSetChanged(int) + * @see #notifySectionHeaderChanged(int) + * @see #notifySectionItemChanged(int, int) + * @see #notifySectionInserted(int) + * @see #notifySectionItemInserted(int, int) + * @see #notifySectionItemRangeInserted(int, int, int) + * @see #notifySectionRemoved(int) + * @see #notifySectionItemRemoved(int, int) + * @see #notifySectionItemRangeRemoved(int, int, int) + */ + public void notifyAllSectionsDataSetChanged() { + calculateSections(); + notifyDataSetChanged(); + } + + public void notifySectionDataSetChanged(int section) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + notifyItemRangeChanged(sectionObject.position, sectionObject.length); + } + } + + public void notifySectionHeaderChanged(int section) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + notifyItemRangeChanged(sectionObject.position, 1); + } + } + + public void notifySectionItemChanged(int section, int position) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + + if (position >= sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + position + ", size is " + sectionObject.itemNumber); + } + + notifyItemChanged(sectionObject.position + position + 1); + } + } + + public void notifySectionInserted(int section) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + notifyItemRangeInserted(sectionObject.position, sectionObject.length); + } + } + + public void notifySectionItemInserted(int section, int position) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + + if (position < 0 || position >= sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + position + ", size is " + sectionObject.itemNumber); + } + + notifyItemInserted(sectionObject.position + position + 1); + } + } + + public void notifySectionItemRangeInserted(int section, int position, int count) { + calculateSections(); + if (mSections == null) { + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + + if (position < 0 || position >= sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + position + ", size is " + sectionObject.itemNumber); + } + if (position + count > sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + (position + count) + ", size is " + sectionObject.itemNumber); + } + + notifyItemRangeInserted(sectionObject.position + position + 1, count); + } + } + + public void notifySectionRemoved(int section) { + if (mSections == null) { + calculateSections(); + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + calculateSections(); + notifyItemRangeRemoved(sectionObject.position, sectionObject.length); + } + } + + public void notifySectionItemRemoved(int section, int position) { + if (mSections == null) { + calculateSections(); + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + + if (position < 0 || position >= sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + position + ", size is " + sectionObject.itemNumber); + } + + calculateSections(); + notifyItemRemoved(sectionObject.position + position + 1); + } + } + + public void notifySectionItemRangeRemoved(int section, int position, int count) { + if (mSections == null) { + calculateSections(); + notifyAllSectionsDataSetChanged(); + } + else { + final Section sectionObject = mSections.get(section); + + if (position < 0 || position >= sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + position + ", size is " + sectionObject.itemNumber); + } + if (position + count > sectionObject.itemNumber) { + throw new IndexOutOfBoundsException("Invalid index " + (position + count) + ", size is " + sectionObject.itemNumber); + } + + calculateSections(); + notifyItemRangeRemoved(sectionObject.position + position + 1, count); + } + } +} diff --git a/sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridLayoutManager.java b/sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridLayoutManager.java new file mode 100644 index 000000000..5390435ed --- /dev/null +++ b/sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridLayoutManager.java @@ -0,0 +1,1368 @@ +package com.codewaves.stickyheadergrid; + +import android.content.Context; +import android.graphics.PointF; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.LinearSmoothScroller; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.Arrays; + +import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; +import static com.codewaves.stickyheadergrid.StickyHeaderGridAdapter.TYPE_HEADER; +import static com.codewaves.stickyheadergrid.StickyHeaderGridAdapter.TYPE_ITEM; + +/** + * Created by Sergej Kravcenko on 4/24/2017. + * Copyright (c) 2017 Sergej Kravcenko + */ + +@SuppressWarnings({"unused", "WeakerAccess"}) +public class StickyHeaderGridLayoutManager extends RecyclerView.LayoutManager implements RecyclerView.SmoothScroller.ScrollVectorProvider { + public static final String TAG = "StickyLayoutManager"; + + private static final int DEFAULT_ROW_COUNT = 16; + + private int mSpanCount; + private SpanSizeLookup mSpanSizeLookup = new DefaultSpanSizeLookup(); + + private StickyHeaderGridAdapter mAdapter; + + private int mHeadersStartPosition; + + private View mFloatingHeaderView; + private int mFloatingHeaderPosition; + private int mStickOffset; + private int mAverageHeaderHeight; + private int mHeaderOverlapMargin; + + private HeaderStateChangeListener mHeaderStateListener; + private int mStickyHeaderSection = NO_POSITION; + private View mStickyHeaderView; + private HeaderState mStickyHeadeState; + + private View mFillViewSet[]; + + private SavedState mPendingSavedState; + private int mPendingScrollPosition = NO_POSITION; + private int mPendingScrollPositionOffset; + private AnchorPosition mAnchor = new AnchorPosition(); + + private final FillResult mFillResult = new FillResult(); + private ArrayList mLayoutRows = new ArrayList<>(DEFAULT_ROW_COUNT); + + public enum HeaderState { + NORMAL, + STICKY, + PUSHED + } + + /** + * The interface to be implemented by listeners to header events from this + * LayoutManager. + */ + public interface HeaderStateChangeListener { + /** + * Called when a section header state changes. The position can be HeaderState.NORMAL, + * HeaderState.STICKY, HeaderState.PUSHED. + * + *

+ *

    + *
  • NORMAL - the section header is invisible or has normal position
  • + *
  • STICKY - the section header is sticky at the top of RecyclerView
  • + *
  • PUSHED - the section header is sticky and pushed up by next header
  • + *
0) { + state.mAnchorSection = mAnchor.section; + state.mAnchorItem = mAnchor.item; + state.mAnchorOffset = mAnchor.offset; + } + else { + state.invalidateAnchor(); + } + + return state; + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + if (state instanceof SavedState) { + mPendingSavedState = (SavedState) state; + requestLayout(); + } + } + + @Override + public boolean checkLayoutParams(RecyclerView.LayoutParams lp) { + return lp instanceof LayoutParams; + } + + @Override + public boolean canScrollVertically() { + return true; + } + + /** + *

Scroll the RecyclerView to make the position visible.

+ * + *

RecyclerView will scroll the minimum amount that is necessary to make the + * target position visible. + * + *

Note that scroll position change will not be reflected until the next layout call.

+ * + * @param position Scroll to this adapter position + */ + @Override + public void scrollToPosition(int position) { + if (position < 0 || position > getItemCount()) { + throw new IndexOutOfBoundsException("adapter position out of range"); + } + + mPendingScrollPosition = position; + mPendingScrollPositionOffset = 0; + if (mPendingSavedState != null) { + mPendingSavedState.invalidateAnchor(); + } + requestLayout(); + } + + private int getExtraLayoutSpace(RecyclerView.State state) { + if (state.hasTargetScrollPosition()) { + return getHeight(); + } + else { + return 0; + } + } + + @Override + public void smoothScrollToPosition(final RecyclerView recyclerView, RecyclerView.State state, int position) { + final LinearSmoothScroller linearSmoothScroller = new LinearSmoothScroller(recyclerView.getContext()) { + @Override + public int calculateDyToMakeVisible(View view, int snapPreference) { + final RecyclerView.LayoutManager layoutManager = getLayoutManager(); + if (layoutManager == null || !layoutManager.canScrollVertically()) { + return 0; + } + + final int adapterPosition = getPosition(view); + final int topOffset = getPositionSectionHeaderHeight(adapterPosition); + final int top = layoutManager.getDecoratedTop(view); + final int bottom = layoutManager.getDecoratedBottom(view); + final int start = layoutManager.getPaddingTop() + topOffset; + final int end = layoutManager.getHeight() - layoutManager.getPaddingBottom(); + return calculateDtToFit(top, bottom, start, end, snapPreference); + } + }; + linearSmoothScroller.setTargetPosition(position); + startSmoothScroll(linearSmoothScroller); + } + + @Override + public PointF computeScrollVectorForPosition(int targetPosition) { + if (getChildCount() == 0) { + return null; + } + + final LayoutRow firstRow = getFirstVisibleRow(); + if (firstRow == null) { + return null; + } + + return new PointF(0, targetPosition - firstRow.adapterPosition); + } + + private int getAdapterPositionFromAnchor(AnchorPosition anchor) { + if (anchor.section < 0 || anchor.section >= mAdapter.getSectionCount()) { + anchor.reset(); + return NO_POSITION; + } + else if (anchor.item < 0 || anchor.item >= mAdapter.getSectionItemCount(anchor.section)) { + anchor.offset = 0; + return mAdapter.getSectionHeaderPosition(anchor.section); + } + return mAdapter.getSectionItemPosition(anchor.section, anchor.item); + } + + private int getAdapterPositionChecked(int section, int offset) { + if (section < 0 || section >= mAdapter.getSectionCount()) { + return NO_POSITION; + } + else if (offset < 0 || offset >= mAdapter.getSectionItemCount(section)) { + return mAdapter.getSectionHeaderPosition(section); + } + return mAdapter.getSectionItemPosition(section, offset); + } + + @Override + public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { + if (mAdapter == null || state.getItemCount() == 0) { + removeAndRecycleAllViews(recycler); + clearState(); + return; + } + + int pendingAdapterPosition; + int pendingAdapterOffset; + if (mPendingScrollPosition >= 0) { + pendingAdapterPosition = mPendingScrollPosition; + pendingAdapterOffset = mPendingScrollPositionOffset; + } + else if (mPendingSavedState != null && mPendingSavedState.hasValidAnchor()) { + pendingAdapterPosition = getAdapterPositionChecked(mPendingSavedState.mAnchorSection, mPendingSavedState.mAnchorItem); + pendingAdapterOffset = mPendingSavedState.mAnchorOffset; + mPendingSavedState = null; + } + else { + pendingAdapterPosition = getAdapterPositionFromAnchor(mAnchor); + pendingAdapterOffset = mAnchor.offset; + } + + if (pendingAdapterPosition < 0 || pendingAdapterPosition >= state.getItemCount()) { + pendingAdapterPosition = 0; + pendingAdapterOffset = 0; + mPendingScrollPosition = NO_POSITION; + } + + if (pendingAdapterOffset > 0) { + pendingAdapterOffset = 0; + } + + detachAndScrapAttachedViews(recycler); + clearState(); + + // Make sure mFirstViewPosition is the start of the row + pendingAdapterPosition = findFirstRowItem(pendingAdapterPosition); + + int left = getPaddingLeft(); + int right = getWidth() - getPaddingRight(); + final int recyclerBottom = getHeight() - getPaddingBottom(); + int totalHeight = 0; + + int adapterPosition = pendingAdapterPosition; + int top = getPaddingTop() + pendingAdapterOffset; + while (true) { + if (adapterPosition >= state.getItemCount()) { + break; + } + + int bottom; + final int viewType = mAdapter.getItemViewInternalType(adapterPosition); + if (viewType == TYPE_HEADER) { + final View v = recycler.getViewForPosition(adapterPosition); + addView(v); + measureChildWithMargins(v, 0, 0); + + int height = getDecoratedMeasuredHeight(v); + final int margin = height >= mHeaderOverlapMargin ? mHeaderOverlapMargin : height; + bottom = top + height; + layoutDecorated(v, left, top, right, bottom); + + bottom -= margin; + height -= margin; + mLayoutRows.add(new LayoutRow(v, adapterPosition, 1, top, bottom)); + adapterPosition++; + mAverageHeaderHeight = height; + } + else { + final FillResult result = fillBottomRow(recycler, state, adapterPosition, top); + bottom = top + result.height; + mLayoutRows.add(new LayoutRow(result.adapterPosition, result.length, top, bottom)); + adapterPosition += result.length; + } + top = bottom; + + if (bottom >= recyclerBottom + getExtraLayoutSpace(state)) { + break; + } + } + + if (getBottomRow().bottom < recyclerBottom) { + scrollVerticallyBy(getBottomRow().bottom - recyclerBottom, recycler, state); + } + else { + clearViewsAndStickHeaders(recycler, state, false); + } + + // If layout was caused by the pending scroll, adjust top item position and move it under sticky header + if (mPendingScrollPosition >= 0) { + mPendingScrollPosition = NO_POSITION; + + final int topOffset = getPositionSectionHeaderHeight(pendingAdapterPosition); + if (topOffset != 0) { + scrollVerticallyBy(-topOffset, recycler, state); + } + } + } + + @Override + public void onLayoutCompleted(RecyclerView.State state) { + super.onLayoutCompleted(state); + mPendingSavedState = null; + } + + private int getPositionSectionHeaderHeight(int adapterPosition) { + final int section = mAdapter.getAdapterPositionSection(adapterPosition); + if (section >= 0 && mAdapter.isSectionHeaderSticky(section)) { + final int offset = mAdapter.getItemSectionOffset(section, adapterPosition); + if (offset >= 0) { + final int headerAdapterPosition = mAdapter.getSectionHeaderPosition(section); + if (mFloatingHeaderView != null && headerAdapterPosition == mFloatingHeaderPosition) { + return Math.max(0, getDecoratedMeasuredHeight(mFloatingHeaderView) - mHeaderOverlapMargin); + } + else { + final LayoutRow header = getHeaderRow(headerAdapterPosition); + if (header != null) { + return header.getHeight(); + } + else { + // Fall back to cached header size, can be incorrect + return mAverageHeaderHeight; + } + } + } + } + + return 0; + } + + private int findFirstRowItem(int adapterPosition) { + final int section = mAdapter.getAdapterPositionSection(adapterPosition); + int sectionPosition = mAdapter.getItemSectionOffset(section, adapterPosition); + while (sectionPosition > 0 && mSpanSizeLookup.getSpanIndex(section, sectionPosition, mSpanCount) != 0) { + sectionPosition--; + adapterPosition--; + } + + return adapterPosition; + } + + private int getSpanWidth(int recyclerWidth, int spanIndex, int spanSize) { + final int spanWidth = recyclerWidth / mSpanCount; + final int spanWidthReminder = recyclerWidth - spanWidth * mSpanCount; + final int widthCorrection = Math.min(Math.max(0, spanWidthReminder - spanIndex), spanSize); + + return spanWidth * spanSize + widthCorrection; + } + + private int getSpanLeft(int recyclerWidth, int spanIndex) { + final int spanWidth = recyclerWidth / mSpanCount; + final int spanWidthReminder = recyclerWidth - spanWidth * mSpanCount; + final int widthCorrection = Math.min(spanWidthReminder, spanIndex); + + return spanWidth * spanIndex + widthCorrection; + } + + private FillResult fillBottomRow(RecyclerView.Recycler recycler, RecyclerView.State state, int position, int top) { + final int recyclerWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + final int section = mAdapter.getAdapterPositionSection(position); + int adapterPosition = position; + int sectionPosition = mAdapter.getItemSectionOffset(section, adapterPosition); + int spanSize = mSpanSizeLookup.getSpanSize(section, sectionPosition); + int spanIndex = mSpanSizeLookup.getSpanIndex(section, sectionPosition, mSpanCount); + int count = 0; + int maxHeight = 0; + + // Create phase + Arrays.fill(mFillViewSet, null); + while (spanIndex + spanSize <= mSpanCount) { + // Create view and fill layout params + final int spanWidth = getSpanWidth(recyclerWidth, spanIndex, spanSize); + final View v = recycler.getViewForPosition(adapterPosition); + final LayoutParams params = (LayoutParams)v.getLayoutParams(); + params.mSpanIndex = spanIndex; + params.mSpanSize = spanSize; + + addView(v, mHeadersStartPosition); + mHeadersStartPosition++; + measureChildWithMargins(v, recyclerWidth - spanWidth, 0); + mFillViewSet[count] = v; + count++; + + final int height = getDecoratedMeasuredHeight(v); + if (maxHeight < height) { + maxHeight = height; + } + + // Check next + adapterPosition++; + sectionPosition++; + if (sectionPosition >= mAdapter.getSectionItemCount(section)) { + break; + } + + spanIndex += spanSize; + spanSize = mSpanSizeLookup.getSpanSize(section, sectionPosition); + } + + // Layout phase + int left = getPaddingLeft(); + for (int i = 0; i < count; ++i) { + final View v = mFillViewSet[i]; + final int height = getDecoratedMeasuredHeight(v); + final int width = getDecoratedMeasuredWidth(v); + layoutDecorated(v, left, top, left + width, top + height); + left += width; + } + + mFillResult.edgeView = mFillViewSet[count - 1]; + mFillResult.adapterPosition = position; + mFillResult.length = count; + mFillResult.height = maxHeight; + + return mFillResult; + } + + private FillResult fillTopRow(RecyclerView.Recycler recycler, RecyclerView.State state, int position, int top) { + final int recyclerWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + final int section = mAdapter.getAdapterPositionSection(position); + int adapterPosition = position; + int sectionPosition = mAdapter.getItemSectionOffset(section, adapterPosition); + int spanSize = mSpanSizeLookup.getSpanSize(section, sectionPosition); + int spanIndex = mSpanSizeLookup.getSpanIndex(section, sectionPosition, mSpanCount); + int count = 0; + int maxHeight = 0; + + Arrays.fill(mFillViewSet, null); + while (spanIndex >= 0) { + // Create view and fill layout params + final int spanWidth = getSpanWidth(recyclerWidth, spanIndex, spanSize); + final View v = recycler.getViewForPosition(adapterPosition); + final LayoutParams params = (LayoutParams)v.getLayoutParams(); + params.mSpanIndex = spanIndex; + params.mSpanSize = spanSize; + + addView(v, 0); + mHeadersStartPosition++; + measureChildWithMargins(v, recyclerWidth - spanWidth, 0); + mFillViewSet[count] = v; + count++; + + final int height = getDecoratedMeasuredHeight(v); + if (maxHeight < height) { + maxHeight = height; + } + + // Check next + adapterPosition--; + sectionPosition--; + if (sectionPosition < 0) { + break; + } + + spanSize = mSpanSizeLookup.getSpanSize(section, sectionPosition); + spanIndex -= spanSize; + } + + // Layout phase + int left = getPaddingLeft(); + for (int i = count - 1; i >= 0; --i) { + final View v = mFillViewSet[i]; + final int height = getDecoratedMeasuredHeight(v); + final int width = getDecoratedMeasuredWidth(v); + layoutDecorated(v, left, top - maxHeight, left + width, top - (maxHeight - height)); + left += width; + } + + mFillResult.edgeView = mFillViewSet[count - 1]; + mFillResult.adapterPosition = adapterPosition + 1; + mFillResult.length = count; + mFillResult.height = maxHeight; + + return mFillResult; + } + + private void clearHiddenRows(RecyclerView.Recycler recycler, RecyclerView.State state, boolean top) { + if (mLayoutRows.size() <= 0) { + return; + } + + final int recyclerTop = getPaddingTop(); + final int recyclerBottom = getHeight() - getPaddingBottom(); + + if (top) { + LayoutRow row = getTopRow(); + while (row.bottom < recyclerTop - getExtraLayoutSpace(state) || row.top > recyclerBottom) { + if (row.header) { + removeAndRecycleViewAt(mHeadersStartPosition + (mFloatingHeaderView != null ? 1 : 0), recycler); + } + else { + for (int i = 0; i < row.length; ++i) { + removeAndRecycleViewAt(0, recycler); + mHeadersStartPosition--; + } + } + mLayoutRows.remove(0); + row = getTopRow(); + } + } + else { + LayoutRow row = getBottomRow(); + while (row.bottom < recyclerTop || row.top > recyclerBottom + getExtraLayoutSpace(state)) { + if (row.header) { + removeAndRecycleViewAt(getChildCount() - 1, recycler); + } + else { + for (int i = 0; i < row.length; ++i) { + removeAndRecycleViewAt(mHeadersStartPosition - 1, recycler); + mHeadersStartPosition--; + } + } + mLayoutRows.remove(mLayoutRows.size() - 1); + row = getBottomRow(); + } + } + } + + private void clearViewsAndStickHeaders(RecyclerView.Recycler recycler, RecyclerView.State state, boolean top) { + clearHiddenRows(recycler, state, top); + if (getChildCount() > 0) { + stickTopHeader(recycler); + } + updateTopPosition(); + } + + private LayoutRow getBottomRow() { + return mLayoutRows.get(mLayoutRows.size() - 1); + } + + private LayoutRow getTopRow() { + return mLayoutRows.get(0); + } + + private void offsetRowsVertical(int offset) { + for (LayoutRow row : mLayoutRows) { + row.top += offset; + row.bottom += offset; + } + offsetChildrenVertical(offset); + } + + private void addRow(RecyclerView.Recycler recycler, RecyclerView.State state, boolean isTop, int adapterPosition, int top) { + final int left = getPaddingLeft(); + final int right = getWidth() - getPaddingRight(); + + // Reattach floating header if needed + if (isTop && mFloatingHeaderView != null && adapterPosition == mFloatingHeaderPosition) { + removeFloatingHeader(recycler); + } + + final int viewType = mAdapter.getItemViewInternalType(adapterPosition); + if (viewType == TYPE_HEADER) { + final View v = recycler.getViewForPosition(adapterPosition); + if (isTop) { + addView(v, mHeadersStartPosition); + } + else { + addView(v); + } + measureChildWithMargins(v, 0, 0); + final int height = getDecoratedMeasuredHeight(v); + final int margin = height >= mHeaderOverlapMargin ? mHeaderOverlapMargin : height; + if (isTop) { + layoutDecorated(v, left, top - height + margin, right, top + margin); + mLayoutRows.add(0, new LayoutRow(v, adapterPosition, 1, top - height + margin, top)); + } + else { + layoutDecorated(v, left, top, right, top + height); + mLayoutRows.add(new LayoutRow(v, adapterPosition, 1, top, top + height - margin)); + } + mAverageHeaderHeight = height - margin; + } + else { + if (isTop) { + final FillResult result = fillTopRow(recycler, state, adapterPosition, top); + mLayoutRows.add(0, new LayoutRow(result.adapterPosition, result.length, top - result.height, top)); + } + else { + final FillResult result = fillBottomRow(recycler, state, adapterPosition, top); + mLayoutRows.add(new LayoutRow(result.adapterPosition, result.length, top, top + result.height)); + } + } + } + + private void addOffScreenRows(RecyclerView.Recycler recycler, RecyclerView.State state, int recyclerTop, int recyclerBottom, boolean bottom) { + if (bottom) { + // Bottom + while (true) { + final LayoutRow bottomRow = getBottomRow(); + final int adapterPosition = bottomRow.adapterPosition + bottomRow.length; + if (bottomRow.bottom >= recyclerBottom + getExtraLayoutSpace(state) || adapterPosition >= state.getItemCount()) { + break; + } + addRow(recycler, state, false, adapterPosition, bottomRow.bottom); + } + } + else { + // Top + while (true) { + final LayoutRow topRow = getTopRow(); + final int adapterPosition = topRow.adapterPosition - 1; + if (topRow.top < recyclerTop - getExtraLayoutSpace(state) || adapterPosition < 0) { + break; + } + addRow(recycler, state, true, adapterPosition, topRow.top); + } + } + } + + @Override + public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { + if (getChildCount() == 0) { + return 0; + } + + int scrolled = 0; + int left = getPaddingLeft(); + int right = getWidth() - getPaddingRight(); + final int recyclerTop = getPaddingTop(); + final int recyclerBottom = getHeight() - getPaddingBottom(); + + // If we have simple header stick, offset it back + final int firstHeader = getFirstVisibleSectionHeader(); + if (firstHeader != NO_POSITION) { + mLayoutRows.get(firstHeader).headerView.offsetTopAndBottom(-mStickOffset); + } + + if (dy >= 0) { + // Up + while (scrolled < dy) { + final LayoutRow bottomRow = getBottomRow(); + final int scrollChunk = -Math.min(Math.max(bottomRow.bottom - recyclerBottom, 0), dy - scrolled); + + offsetRowsVertical(scrollChunk); + scrolled -= scrollChunk; + + final int adapterPosition = bottomRow.adapterPosition + bottomRow.length; + if (scrolled >= dy || adapterPosition >= state.getItemCount()) { + break; + } + + addRow(recycler, state, false, adapterPosition, bottomRow.bottom); + } + } + else { + // Down + while (scrolled > dy) { + final LayoutRow topRow = getTopRow(); + final int scrollChunk = Math.min(Math.max(-topRow.top + recyclerTop, 0), scrolled - dy); + + offsetRowsVertical(scrollChunk); + scrolled -= scrollChunk; + + final int adapterPosition = topRow.adapterPosition - 1; + if (scrolled <= dy || adapterPosition >= state.getItemCount() || adapterPosition < 0) { + break; + } + + addRow(recycler, state, true, adapterPosition, topRow.top); + } + } + + // Fill extra offscreen rows for smooth scroll + if (scrolled == dy) { + addOffScreenRows(recycler, state, recyclerTop, recyclerBottom, dy >= 0); + } + + clearViewsAndStickHeaders(recycler, state, dy >= 0); + return scrolled; + } + + /** + * Returns first visible item excluding headers. + * + * @param visibleTop Whether item top edge should be visible or not + * @return The first visible item adapter position closest to top of the layout. + */ + public int getFirstVisibleItemPosition(boolean visibleTop) { + return getFirstVisiblePosition(TYPE_ITEM, visibleTop); + } + + /** + * Returns last visible item excluding headers. + * + * @return The last visible item adapter position closest to bottom of the layout. + */ + public int getLastVisibleItemPosition() { + return getLastVisiblePosition(TYPE_ITEM); + } + + /** + * Returns first visible header. + * + * @param visibleTop Whether header top edge should be visible or not + * @return The first visible header adapter position closest to top of the layout. + */ + public int getFirstVisibleHeaderPosition(boolean visibleTop) { + return getFirstVisiblePosition(TYPE_HEADER, visibleTop); + } + + /** + * Returns last visible header. + * + * @return The last visible header adapter position closest to bottom of the layout. + */ + public int getLastVisibleHeaderPosition() { + return getLastVisiblePosition(TYPE_HEADER); + } + + private int getFirstVisiblePosition(int type, boolean visibleTop) { + if (type == TYPE_ITEM && mHeadersStartPosition <= 0) { + return NO_POSITION; + } + else if (type == TYPE_HEADER && mHeadersStartPosition >= getChildCount()) { + return NO_POSITION; + } + + int viewFrom = type == TYPE_ITEM ? 0 : mHeadersStartPosition; + int viewTo = type == TYPE_ITEM ? mHeadersStartPosition : getChildCount(); + final int recyclerTop = getPaddingTop(); + for (int i = viewFrom; i < viewTo; ++i) { + final View v = getChildAt(i); + final int adapterPosition = getPosition(v); + final int headerHeight = getPositionSectionHeaderHeight(adapterPosition); + final int top = getDecoratedTop(v); + final int bottom = getDecoratedBottom(v); + + if (visibleTop) { + if (top >= recyclerTop + headerHeight) { + return adapterPosition; + } + } + else { + if (bottom >= recyclerTop + headerHeight) { + return adapterPosition; + } + } + } + + return NO_POSITION; + } + + private int getLastVisiblePosition(int type) { + if (type == TYPE_ITEM && mHeadersStartPosition <= 0) { + return NO_POSITION; + } + else if (type == TYPE_HEADER && mHeadersStartPosition >= getChildCount()) { + return NO_POSITION; + } + + int viewFrom = type == TYPE_ITEM ? mHeadersStartPosition - 1 : getChildCount() - 1; + int viewTo = type == TYPE_ITEM ? 0 : mHeadersStartPosition; + final int recyclerBottom = getHeight() - getPaddingBottom(); + for (int i = viewFrom; i >= viewTo; --i) { + final View v = getChildAt(i); + final int top = getDecoratedTop(v); + + if (top < recyclerBottom) { + return getPosition(v); + } + } + + return NO_POSITION; + } + + private LayoutRow getFirstVisibleRow() { + final int recyclerTop = getPaddingTop(); + for (LayoutRow row : mLayoutRows) { + if (row.bottom > recyclerTop) { + return row; + } + } + return null; + } + + private int getFirstVisibleSectionHeader() { + final int recyclerTop = getPaddingTop(); + + int header = NO_POSITION; + for (int i = 0, n = mLayoutRows.size(); i < n; ++i) { + final LayoutRow row = mLayoutRows.get(i); + if (row.header) { + header = i; + } + if (row.bottom > recyclerTop) { + return header; + } + } + return NO_POSITION; + } + + private LayoutRow getNextVisibleSectionHeader(int headerFrom) { + for (int i = headerFrom + 1, n = mLayoutRows.size(); i < n; ++i) { + final LayoutRow row = mLayoutRows.get(i); + if (row.header) { + return row; + } + } + return null; + } + + private LayoutRow getHeaderRow(int adapterPosition) { + for (int i = 0, n = mLayoutRows.size(); i < n; ++i) { + final LayoutRow row = mLayoutRows.get(i); + if (row.header && row.adapterPosition == adapterPosition) { + return row; + } + } + return null; + } + + private void removeFloatingHeader(RecyclerView.Recycler recycler) { + if (mFloatingHeaderView == null) { + return; + } + + final View view = mFloatingHeaderView; + mFloatingHeaderView = null; + mFloatingHeaderPosition = NO_POSITION; + removeAndRecycleView(view, recycler); + } + + private void onHeaderChanged(int section, View view, HeaderState state, int pushOffset) { + if (mStickyHeaderSection != NO_POSITION && section != mStickyHeaderSection) { + onHeaderUnstick(); + } + + final boolean headerStateChanged = mStickyHeaderSection != section || !mStickyHeadeState.equals(state) || state.equals(HeaderState.PUSHED); + + mStickyHeaderSection = section; + mStickyHeaderView = view; + mStickyHeadeState = state; + + if (headerStateChanged && mHeaderStateListener != null) { + mHeaderStateListener.onHeaderStateChanged(section, view, state, pushOffset); + } + } + + private void onHeaderUnstick() { + if (mStickyHeaderSection != NO_POSITION) { + if (mHeaderStateListener != null) { + mHeaderStateListener.onHeaderStateChanged(mStickyHeaderSection, mStickyHeaderView, HeaderState.NORMAL, 0); + } + mStickyHeaderSection = NO_POSITION; + mStickyHeaderView = null; + mStickyHeadeState = HeaderState.NORMAL; + } + } + + private void stickTopHeader(RecyclerView.Recycler recycler) { + final int firstHeader = getFirstVisibleSectionHeader(); + final int top = getPaddingTop(); + final int left = getPaddingLeft(); + final int right = getWidth() - getPaddingRight(); + + int notifySection = NO_POSITION; + View notifyView = null; + HeaderState notifyState = HeaderState.NORMAL; + int notifyOffset = 0; + + if (firstHeader != NO_POSITION) { + // Top row is header, floating header is not visible, remove + removeFloatingHeader(recycler); + + final LayoutRow firstHeaderRow = mLayoutRows.get(firstHeader); + final int section = mAdapter.getAdapterPositionSection(firstHeaderRow.adapterPosition); + if (mAdapter.isSectionHeaderSticky(section)) { + final LayoutRow nextHeaderRow = getNextVisibleSectionHeader(firstHeader); + int offset = 0; + if (nextHeaderRow != null) { + final int height = firstHeaderRow.getHeight(); + offset = Math.min(Math.max(top - nextHeaderRow.top, -height) + height, height); + } + + mStickOffset = top - firstHeaderRow.top - offset; + firstHeaderRow.headerView.offsetTopAndBottom(mStickOffset); + + onHeaderChanged(section, firstHeaderRow.headerView, offset == 0 ? HeaderState.STICKY : HeaderState.PUSHED, offset); + } + else { + onHeaderUnstick(); + mStickOffset = 0; + } + } + else { + // We don't have first visible sector header in layout, create floating + final LayoutRow firstVisibleRow = getFirstVisibleRow(); + if (firstVisibleRow != null) { + final int section = mAdapter.getAdapterPositionSection(firstVisibleRow.adapterPosition); + if (mAdapter.isSectionHeaderSticky(section)) { + final int headerPosition = mAdapter.getSectionHeaderPosition(section); + if (mFloatingHeaderView == null || mFloatingHeaderPosition != headerPosition) { + removeFloatingHeader(recycler); + + // Create floating header + final View v = recycler.getViewForPosition(headerPosition); + addView(v, mHeadersStartPosition); + measureChildWithMargins(v, 0, 0); + mFloatingHeaderView = v; + mFloatingHeaderPosition = headerPosition; + } + + // Push floating header up, if needed + final int height = getDecoratedMeasuredHeight(mFloatingHeaderView); + int offset = 0; + if (getChildCount() - mHeadersStartPosition > 1) { + final View nextHeader = getChildAt(mHeadersStartPosition + 1); + final int contentHeight = Math.max(0, height - mHeaderOverlapMargin); + offset = Math.max(top - getDecoratedTop(nextHeader), -contentHeight) + contentHeight; + } + + layoutDecorated(mFloatingHeaderView, left, top - offset, right, top + height - offset); + onHeaderChanged(section, mFloatingHeaderView, offset == 0 ? HeaderState.STICKY : HeaderState.PUSHED, offset); + } + else { + onHeaderUnstick(); + } + } + else { + onHeaderUnstick(); + } + } + } + + private void updateTopPosition() { + if (getChildCount() == 0) { + mAnchor.reset(); + } + + final LayoutRow firstVisibleRow = getFirstVisibleRow(); + if (firstVisibleRow != null) { + mAnchor.section = mAdapter.getAdapterPositionSection(firstVisibleRow.adapterPosition); + mAnchor.item = mAdapter.getItemSectionOffset(mAnchor.section, firstVisibleRow.adapterPosition); + mAnchor.offset = Math.min(firstVisibleRow.top - getPaddingTop(), 0); + } + } + + private int getViewType(View view) { + return getItemViewType(view) & 0xFF; + } + + private int getViewType(int position) { + return mAdapter.getItemViewType(position) & 0xFF; + } + + private void clearState() { + mHeadersStartPosition = 0; + mStickOffset = 0; + mFloatingHeaderView = null; + mFloatingHeaderPosition = -1; + mAverageHeaderHeight = 0; + mLayoutRows.clear(); + + if (mStickyHeaderSection != NO_POSITION) { + if (mHeaderStateListener != null) { + mHeaderStateListener.onHeaderStateChanged(mStickyHeaderSection, mStickyHeaderView, HeaderState.NORMAL, 0); + } + mStickyHeaderSection = NO_POSITION; + mStickyHeaderView = null; + mStickyHeadeState = HeaderState.NORMAL; + } + } + + @Override + public int computeVerticalScrollExtent(RecyclerView.State state) { + if (mHeadersStartPosition == 0 || state.getItemCount() == 0) { + return 0; + } + + final View startChild = getChildAt(0); + final View endChild = getChildAt(mHeadersStartPosition - 1); + if (startChild == null || endChild == null) { + return 0; + } + + return Math.abs(getPosition(startChild) - getPosition(endChild)) + 1; + } + + @Override + public int computeVerticalScrollOffset(RecyclerView.State state) { + if (mHeadersStartPosition == 0 || state.getItemCount() == 0) { + return 0; + } + + final View startChild = getChildAt(0); + final View endChild = getChildAt(mHeadersStartPosition - 1); + if (startChild == null || endChild == null) { + return 0; + } + + final int recyclerTop = getPaddingTop(); + final LayoutRow topRow = getTopRow(); + final int scrollChunk = Math.max(-topRow.top + recyclerTop, 0); + if (scrollChunk == 0) { + return 0; + } + + final int minPosition = Math.min(getPosition(startChild), getPosition(endChild)); + final int maxPosition = Math.max(getPosition(startChild), getPosition(endChild)); + return Math.max(0, minPosition); + } + + @Override + public int computeVerticalScrollRange(RecyclerView.State state) { + if (mHeadersStartPosition == 0 || state.getItemCount() == 0) { + return 0; + } + + final View startChild = getChildAt(0); + final View endChild = getChildAt(mHeadersStartPosition - 1); + if (startChild == null || endChild == null) { + return 0; + } + + return state.getItemCount(); + } + + public static class LayoutParams extends RecyclerView.LayoutParams { + public static final int INVALID_SPAN_ID = -1; + + private int mSpanIndex = INVALID_SPAN_ID; + private int mSpanSize = 0; + + public LayoutParams(Context c, AttributeSet attrs) { + super(c, attrs); + } + + public LayoutParams(int width, int height) { + super(width, height); + } + + public LayoutParams(ViewGroup.MarginLayoutParams source) { + super(source); + } + + public LayoutParams(ViewGroup.LayoutParams source) { + super(source); + } + + public LayoutParams(RecyclerView.LayoutParams source) { + super(source); + } + + public int getSpanIndex() { + return mSpanIndex; + } + + public int getSpanSize() { + return mSpanSize; + } + } + + public static final class DefaultSpanSizeLookup extends SpanSizeLookup { + @Override + public int getSpanSize(int section, int position) { + return 1; + } + + @Override + public int getSpanIndex(int section, int position, int spanCount) { + return position % spanCount; + } + } + + /** + * An interface to provide the number of spans each item occupies. + *

+ * Default implementation sets each item to occupy exactly 1 span. + * + * @see StickyHeaderGridLayoutManager#setSpanSizeLookup(StickyHeaderGridLayoutManager.SpanSizeLookup) + */ + public static abstract class SpanSizeLookup { + /** + * Returns the number of span occupied by the item in section at position. + * + * @param section The adapter section of the item + * @param position The adapter position of the item in section + * @return The number of spans occupied by the item at the provided section and position + */ + abstract public int getSpanSize(int section, int position); + + /** + * Returns the final span index of the provided position. + * + *

+ * If you override this method, you need to make sure it is consistent with + * {@link #getSpanSize(int, int)}. StickyHeaderGridLayoutManager does not call this method for + * each item. It is called only for the reference item and rest of the items + * are assigned to spans based on the reference item. For example, you cannot assign a + * position to span 2 while span 1 is empty. + *

+ * + * @param section The adapter section of the item + * @param position The adapter position of the item in section + * @param spanCount The total number of spans in the grid + * @return The final span position of the item. Should be between 0 (inclusive) and + * spanCount(exclusive) + */ + public int getSpanIndex(int section, int position, int spanCount) { + // TODO: cache them? + final int positionSpanSize = getSpanSize(section, position); + if (positionSpanSize >= spanCount) { + return 0; + } + + int spanIndex = 0; + for (int i = 0; i < position; ++i) { + final int spanSize = getSpanSize(section, i); + spanIndex += spanSize; + + if (spanIndex == spanCount) { + spanIndex = 0; + } + else if (spanIndex > spanCount) { + spanIndex = spanSize; + } + } + + if (spanIndex + positionSpanSize <= spanCount) { + return spanIndex; + } + + return 0; + } + } + + public static class SavedState implements Parcelable { + private int mAnchorSection; + private int mAnchorItem; + private int mAnchorOffset; + + public SavedState() { + + } + + SavedState(Parcel in) { + mAnchorSection = in.readInt(); + mAnchorItem = in.readInt(); + mAnchorOffset = in.readInt(); + } + + public SavedState(SavedState other) { + mAnchorSection = other.mAnchorSection; + mAnchorItem = other.mAnchorItem; + mAnchorOffset = other.mAnchorOffset; + } + + boolean hasValidAnchor() { + return mAnchorSection >= 0; + } + + void invalidateAnchor() { + mAnchorSection = NO_POSITION; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(mAnchorSection); + dest.writeInt(mAnchorItem); + dest.writeInt(mAnchorOffset); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + private static class LayoutRow { + private boolean header; + private View headerView; + private int adapterPosition; + private int length; + private int top; + private int bottom; + + public LayoutRow(int adapterPosition, int length, int top, int bottom) { + this.header = false; + this.headerView = null; + this.adapterPosition = adapterPosition; + this.length = length; + this.top = top; + this.bottom = bottom; + } + + public LayoutRow(View headerView, int adapterPosition, int length, int top, int bottom) { + this.header = true; + this.headerView = headerView; + this.adapterPosition = adapterPosition; + this.length = length; + this.top = top; + this.bottom = bottom; + } + + int getHeight() { + return bottom - top; + } + } + + private static class FillResult { + private View edgeView; + private int adapterPosition; + private int length; + private int height; + } + + private static class AnchorPosition { + private int section; + private int item; + private int offset; + + public AnchorPosition() { + reset(); + } + + public void reset() { + section = NO_POSITION; + item = 0; + offset = 0; + } + } +}