From cf71ca682e4b8ad11c25bedf5d011ec81c819029 Mon Sep 17 00:00:00 2001 From: Ammar Githam Date: Fri, 14 May 2021 00:53:23 +0900 Subject: [PATCH] Update keyboard/emojipicker visiblity logic. Fixes austinhuang0131/barinsta#1181. Also check description. This commits adds some special handling for Android 11+ users regarding keyboard visibility. Check https://github.com/android/user-interface-samples/tree/master/WindowInsetsAnimation. --- app/build.gradle | 7 +- app/src/main/AndroidManifest.xml | 3 +- .../instagrabber/activities/MainActivity.java | 14 +- .../InsetsAnimationLinearLayout.java | 246 ++++++++ .../InsetsNotifyingCoordinatorLayout.java | 33 + .../InsetsNotifyingLinearLayout.java | 35 ++ .../ControlFocusInsetsAnimationCallback.java | 87 +++ .../EmojiPickerInsetsAnimationCallback.java | 117 ++++ .../RootViewDeferringInsetsCallback.java | 139 +++++ .../helpers/SimpleImeAnimationController.java | 443 ++++++++++++++ ...slateDeferringInsetsAnimationCallback.java | 128 ++++ .../DirectMessageThreadFragment.java | 396 ++++++------ .../java/awais/instagrabber/utils/Utils.java | 37 ++ .../awais/instagrabber/utils/ViewUtils.java | 51 ++ app/src/main/res/layout/activity_main.xml | 5 +- .../fragment_direct_messages_thread.xml | 563 +++++++++--------- 16 files changed, 1830 insertions(+), 474 deletions(-) create mode 100644 app/src/main/java/awais/instagrabber/customviews/InsetsAnimationLinearLayout.java create mode 100644 app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingCoordinatorLayout.java create mode 100644 app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingLinearLayout.java create mode 100644 app/src/main/java/awais/instagrabber/customviews/helpers/ControlFocusInsetsAnimationCallback.java create mode 100644 app/src/main/java/awais/instagrabber/customviews/helpers/EmojiPickerInsetsAnimationCallback.java create mode 100644 app/src/main/java/awais/instagrabber/customviews/helpers/RootViewDeferringInsetsCallback.java create mode 100644 app/src/main/java/awais/instagrabber/customviews/helpers/SimpleImeAnimationController.java create mode 100644 app/src/main/java/awais/instagrabber/customviews/helpers/TranslateDeferringInsetsAnimationCallback.java diff --git a/app/build.gradle b/app/build.gradle index d8ee6ee6..9a4fe62b 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,7 +12,7 @@ def getGitHash = { -> } android { - compileSdkVersion 29 + compileSdkVersion 30 defaultConfig { applicationId 'me.austinhuang.instagrabber' @@ -165,8 +165,6 @@ dependencies { implementation "com.google.android.exoplayer:exoplayer-dash:$exoplayer_version" implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version" - implementation "androidx.appcompat:appcompat:$appcompat_version" - implementation "androidx.appcompat:appcompat-resources:$appcompat_version" implementation "androidx.recyclerview:recyclerview:1.2.0" implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation "androidx.viewpager2:viewpager2:1.0.0" @@ -180,6 +178,9 @@ dependencies { implementation 'com.google.guava:guava:27.0.1-android' + def core_version = "1.6.0-alpha03" + implementation "androidx.core:core:$core_version" + // Room def room_version = "2.2.6" implementation "androidx.room:room-runtime:$room_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ed170657..f90a4aea 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,8 +27,7 @@ + android:taskAffinity=".Main"> diff --git a/app/src/main/java/awais/instagrabber/activities/MainActivity.java b/app/src/main/java/awais/instagrabber/activities/MainActivity.java index 9b052c8c..00bc847d 100644 --- a/app/src/main/java/awais/instagrabber/activities/MainActivity.java +++ b/app/src/main/java/awais/instagrabber/activities/MainActivity.java @@ -31,6 +31,9 @@ import androidx.appcompat.widget.Toolbar; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.app.NotificationManagerCompat; import androidx.core.provider.FontRequest; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.emoji.text.EmojiCompat; import androidx.emoji.text.FontRequestEmojiCompatConfig; import androidx.fragment.app.FragmentManager; @@ -61,6 +64,7 @@ import awais.instagrabber.BuildConfig; import awais.instagrabber.R; import awais.instagrabber.asyncs.PostFetcher; import awais.instagrabber.customviews.emoji.EmojiVariantManager; +import awais.instagrabber.customviews.helpers.RootViewDeferringInsetsCallback; import awais.instagrabber.customviews.helpers.TextWatcherAdapter; import awais.instagrabber.databinding.ActivityMainBinding; import awais.instagrabber.fragments.PostViewV2Fragment; @@ -137,11 +141,19 @@ public class MainActivity extends BaseLanguageActivity implements FragmentManage instance = this; binding = ActivityMainBinding.inflate(getLayoutInflater()); setupCookie(); - if (settingsHelper.getBoolean(Constants.FLAG_SECURE)) + if (settingsHelper.getBoolean(Constants.FLAG_SECURE)) { getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); + } setContentView(binding.getRoot()); final Toolbar toolbar = binding.toolbar; setSupportActionBar(toolbar); + final RootViewDeferringInsetsCallback deferringInsetsCallback = new RootViewDeferringInsetsCallback( + WindowInsetsCompat.Type.systemBars(), + WindowInsetsCompat.Type.ime() + ); + ViewCompat.setWindowInsetsAnimationCallback(binding.getRoot(), deferringInsetsCallback); + ViewCompat.setOnApplyWindowInsetsListener(binding.getRoot(), deferringInsetsCallback); + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); createNotificationChannels(); try { final CoordinatorLayout.LayoutParams layoutParams = (CoordinatorLayout.LayoutParams) binding.bottomNavView.getLayoutParams(); diff --git a/app/src/main/java/awais/instagrabber/customviews/InsetsAnimationLinearLayout.java b/app/src/main/java/awais/instagrabber/customviews/InsetsAnimationLinearLayout.java new file mode 100644 index 00000000..3e08924c --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/InsetsAnimationLinearLayout.java @@ -0,0 +1,246 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; +import android.view.WindowInsetsAnimation; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.NestedScrollingParent3; +import androidx.core.view.NestedScrollingParentHelper; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +import java.util.Arrays; + +import awais.instagrabber.customviews.helpers.SimpleImeAnimationController; +import awais.instagrabber.utils.ViewUtils; + +import static androidx.core.view.ViewCompat.TYPE_TOUCH; + +public final class InsetsAnimationLinearLayout extends LinearLayout implements NestedScrollingParent3 { + private final NestedScrollingParentHelper nestedScrollingParentHelper = new NestedScrollingParentHelper(this); + private final SimpleImeAnimationController imeAnimController = new SimpleImeAnimationController(); + private final int[] tempIntArray2 = new int[2]; + private final int[] startViewLocation = new int[2]; + + private View currentNestedScrollingChild; + private int dropNextY; + private boolean scrollImeOffScreenWhenVisible = true; + private boolean scrollImeOnScreenWhenNotVisible = true; + private boolean scrollImeOffScreenWhenVisibleOnFling = false; + private boolean scrollImeOnScreenWhenNotVisibleOnFling = false; + + public InsetsAnimationLinearLayout(final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public InsetsAnimationLinearLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public final boolean getScrollImeOffScreenWhenVisible() { + return scrollImeOffScreenWhenVisible; + } + + public final void setScrollImeOffScreenWhenVisible(boolean scrollImeOffScreenWhenVisible) { + this.scrollImeOffScreenWhenVisible = scrollImeOffScreenWhenVisible; + } + + public final boolean getScrollImeOnScreenWhenNotVisible() { + return scrollImeOnScreenWhenNotVisible; + } + + public final void setScrollImeOnScreenWhenNotVisible(boolean scrollImeOnScreenWhenNotVisible) { + this.scrollImeOnScreenWhenNotVisible = scrollImeOnScreenWhenNotVisible; + } + + public boolean getScrollImeOffScreenWhenVisibleOnFling() { + return scrollImeOffScreenWhenVisibleOnFling; + } + + public void setScrollImeOffScreenWhenVisibleOnFling(final boolean scrollImeOffScreenWhenVisibleOnFling) { + this.scrollImeOffScreenWhenVisibleOnFling = scrollImeOffScreenWhenVisibleOnFling; + } + + public boolean getScrollImeOnScreenWhenNotVisibleOnFling() { + return scrollImeOnScreenWhenNotVisibleOnFling; + } + + public void setScrollImeOnScreenWhenNotVisibleOnFling(final boolean scrollImeOnScreenWhenNotVisibleOnFling) { + this.scrollImeOnScreenWhenNotVisibleOnFling = scrollImeOnScreenWhenNotVisibleOnFling; + } + + public SimpleImeAnimationController getImeAnimController() { + return imeAnimController; + } + + @Override + public boolean onStartNestedScroll(@NonNull final View child, + @NonNull final View target, + final int axes, + final int type) { + return (axes & SCROLL_AXIS_VERTICAL) != 0 && type == TYPE_TOUCH; + } + + @Override + public void onNestedScrollAccepted(@NonNull final View child, + @NonNull final View target, + final int axes, + final int type) { + nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type); + currentNestedScrollingChild = child; + } + + @Override + public void onNestedPreScroll(@NonNull final View target, + final int dx, + final int dy, + @NonNull final int[] consumed, + final int type) { + if (imeAnimController.isInsetAnimationRequestPending()) { + consumed[0] = dx; + consumed[1] = dy; + } else { + int deltaY = dy; + if (dropNextY != 0) { + consumed[1] = dropNextY; + deltaY = dy - dropNextY; + dropNextY = 0; + } + + if (deltaY < 0) { + if (imeAnimController.isInsetAnimationInProgress()) { + consumed[1] -= imeAnimController.insetBy(-deltaY); + } else if (scrollImeOffScreenWhenVisible && !imeAnimController.isInsetAnimationRequestPending()) { + WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(this); + if (rootWindowInsets != null) { + if (rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime())) { + startControlRequest(); + consumed[1] = deltaY; + } + } + } + } + + } + } + + @Override + public void onNestedScroll(@NonNull final View target, + final int dxConsumed, + final int dyConsumed, + final int dxUnconsumed, + final int dyUnconsumed, + final int type, + @NonNull final int[] consumed) { + if (dyUnconsumed > 0) { + if (imeAnimController.isInsetAnimationInProgress()) { + consumed[1] = -imeAnimController.insetBy(-dyUnconsumed); + } else if (scrollImeOnScreenWhenNotVisible && !imeAnimController.isInsetAnimationRequestPending()) { + WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(this); + if (rootWindowInsets != null) { + if (!rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime())) { + startControlRequest(); + consumed[1] = dyUnconsumed; + } + } + } + } + + } + + @Override + public boolean onNestedFling(@NonNull final View target, + final float velocityX, + final float velocityY, + final boolean consumed) { + if (imeAnimController.isInsetAnimationInProgress()) { + imeAnimController.animateToFinish(velocityY); + return true; + } else { + boolean imeVisible = false; + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(this); + if (rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime())) { + imeVisible = true; + } + if (velocityY > 0 && scrollImeOnScreenWhenNotVisibleOnFling && !imeVisible) { + imeAnimController.startAndFling(this, velocityY); + return true; + } else if (velocityY < 0 && scrollImeOffScreenWhenVisibleOnFling && imeVisible) { + imeAnimController.startAndFling(this, velocityY); + return true; + } else { + return false; + } + } + } + + @Override + public void onStopNestedScroll(@NonNull final View target, final int type) { + nestedScrollingParentHelper.onStopNestedScroll(target, type); + if (imeAnimController.isInsetAnimationInProgress() && !imeAnimController.isInsetAnimationFinishing()) { + imeAnimController.animateToFinish(null); + } + reset(); + } + + @Override + public void dispatchWindowInsetsAnimationPrepare(@NonNull final WindowInsetsAnimation animation) { + super.dispatchWindowInsetsAnimationPrepare(animation); + ViewUtils.suppressLayoutCompat(this, false); + } + + private void startControlRequest() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return; + } + ViewUtils.suppressLayoutCompat(this, true); + if (currentNestedScrollingChild != null) { + currentNestedScrollingChild.getLocationInWindow(startViewLocation); + } + imeAnimController.startControlRequest(this, windowInsetsAnimationControllerCompat -> onControllerReady()); + } + + private void onControllerReady() { + if (currentNestedScrollingChild != null) { + imeAnimController.insetBy(0); + int[] location = tempIntArray2; + currentNestedScrollingChild.getLocationInWindow(location); + dropNextY = location[1] - startViewLocation[1]; + } + + } + + private void reset() { + dropNextY = 0; + Arrays.fill(startViewLocation, 0); + ViewUtils.suppressLayoutCompat(this, false); + } + + @Override + public void onNestedScrollAccepted(@NonNull final View child, + @NonNull final View target, + final int axes) { + onNestedScrollAccepted(child, target, axes, TYPE_TOUCH); + } + + @Override + public void onNestedScroll(@NonNull final View target, + final int dxConsumed, + final int dyConsumed, + final int dxUnconsumed, + final int dyUnconsumed, + final int type) { + onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, tempIntArray2); + } + + @Override + public void onStopNestedScroll(@NonNull final View target) { + onStopNestedScroll(target, TYPE_TOUCH); + } +} + diff --git a/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingCoordinatorLayout.java b/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingCoordinatorLayout.java new file mode 100644 index 00000000..13a93e43 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingCoordinatorLayout.java @@ -0,0 +1,33 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.WindowInsets; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; + +public class InsetsNotifyingCoordinatorLayout extends CoordinatorLayout { + + public InsetsNotifyingCoordinatorLayout(@NonNull final Context context) { + super(context); + } + + public InsetsNotifyingCoordinatorLayout(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public InsetsNotifyingCoordinatorLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public WindowInsets onApplyWindowInsets(WindowInsets insets) { + int childCount = getChildCount(); + for (int index = 0; index < childCount; index++) { + getChildAt(index).dispatchApplyWindowInsets(insets); + } + return insets; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingLinearLayout.java b/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingLinearLayout.java new file mode 100644 index 00000000..b2faa4e1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingLinearLayout.java @@ -0,0 +1,35 @@ +package awais.instagrabber.customviews; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.WindowInsets; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; + +public class InsetsNotifyingLinearLayout extends LinearLayout { + public InsetsNotifyingLinearLayout(final Context context) { + super(context); + } + + public InsetsNotifyingLinearLayout(final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public InsetsNotifyingLinearLayout(final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public InsetsNotifyingLinearLayout(final Context context, final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + public WindowInsets onApplyWindowInsets(WindowInsets insets) { + int childCount = getChildCount(); + for (int index = 0; index < childCount; index++) { + getChildAt(index).dispatchApplyWindowInsets(insets); + } + return insets; + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/ControlFocusInsetsAnimationCallback.java b/app/src/main/java/awais/instagrabber/customviews/helpers/ControlFocusInsetsAnimationCallback.java new file mode 100644 index 00000000..e1fda461 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/ControlFocusInsetsAnimationCallback.java @@ -0,0 +1,87 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 awais.instagrabber.customviews.helpers; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsCompat; + +import java.util.List; + +/** + * A [WindowInsetsAnimationCompat.Callback] which will request and clear focus on the given view, + * depending on the [WindowInsetsCompat.Type.ime] visibility state when an IME + * [WindowInsetsAnimationCompat] has finished. + *

+ * This is primarily used when animating the [WindowInsetsCompat.Type.ime], so that the + * appropriate view is focused for accepting input from the IME. + */ +public class ControlFocusInsetsAnimationCallback extends WindowInsetsAnimationCompat.Callback { + + private final View view; + + public ControlFocusInsetsAnimationCallback(@NonNull final View view) { + this(view, DISPATCH_MODE_STOP); + } + + /** + * @param view the view to request/clear focus + * @param dispatchMode The dispatch mode for this callback. + * @see WindowInsetsAnimationCompat.Callback.DispatchMode + */ + public ControlFocusInsetsAnimationCallback(@NonNull final View view, final int dispatchMode) { + super(dispatchMode); + this.view = view; + } + + @NonNull + @Override + public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, + @NonNull final List runningAnimations) { + // no-op and return the insets + return insets; + } + + @Override + public void onEnd(final WindowInsetsAnimationCompat animation) { + if ((animation.getTypeMask() & WindowInsetsCompat.Type.ime()) != 0) { + // The animation has now finished, so we can check the view's focus state. + // We post the check because the rootWindowInsets has not yet been updated, but will + // be in the next message traversal + view.post(this::checkFocus); + } + } + + private void checkFocus() { + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); + boolean imeVisible = false; + if (rootWindowInsets != null) { + imeVisible = rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); + } + if (imeVisible && view.getRootView().findFocus() == null) { + // If the IME will be visible, and there is not a currently focused view in + // the hierarchy, request focus on our view + view.requestFocus(); + } else if (!imeVisible && view.isFocused()) { + // If the IME will not be visible and our view is currently focused, clear the focus + view.clearFocus(); + } + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/EmojiPickerInsetsAnimationCallback.java b/app/src/main/java/awais/instagrabber/customviews/helpers/EmojiPickerInsetsAnimationCallback.java new file mode 100644 index 00000000..125b65c1 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/EmojiPickerInsetsAnimationCallback.java @@ -0,0 +1,117 @@ +package awais.instagrabber.customviews.helpers; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsCompat; + +import java.util.List; + +/** + * A customized {@link TranslateDeferringInsetsAnimationCallback} for the emoji picker + */ +public class EmojiPickerInsetsAnimationCallback extends WindowInsetsAnimationCompat.Callback { + private static final String TAG = EmojiPickerInsetsAnimationCallback.class.getSimpleName(); + + private final View view; + private final int persistentInsetTypes; + private final int deferredInsetTypes; + + private int kbHeight; + private onKbVisibilityChangeListener listener; + private boolean shouldTranslate; + + public EmojiPickerInsetsAnimationCallback(final View view, + final int persistentInsetTypes, + final int deferredInsetTypes) { + this(view, persistentInsetTypes, deferredInsetTypes, DISPATCH_MODE_STOP); + } + + public EmojiPickerInsetsAnimationCallback(final View view, + final int persistentInsetTypes, + final int deferredInsetTypes, + final int dispatchMode) { + super(dispatchMode); + if ((persistentInsetTypes & deferredInsetTypes) != 0) { + throw new IllegalArgumentException("persistentInsetTypes and deferredInsetTypes can not contain " + + "any of same WindowInsetsCompat.Type values"); + } + this.view = view; + this.persistentInsetTypes = persistentInsetTypes; + this.deferredInsetTypes = deferredInsetTypes; + } + + @NonNull + @Override + public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, + @NonNull final List runningAnimations) { + // onProgress() is called when any of the running animations progress... + + // First we get the insets which are potentially deferred + final Insets typesInset = insets.getInsets(deferredInsetTypes); + // Then we get the persistent inset types which are applied as padding during layout + final Insets otherInset = insets.getInsets(persistentInsetTypes); + + // Now that we subtract the two insets, to calculate the difference. We also coerce + // the insets to be >= 0, to make sure we don't use negative insets. + final Insets subtract = Insets.subtract(typesInset, otherInset); + final Insets diff = Insets.max(subtract, Insets.NONE); + + // The resulting `diff` insets contain the values for us to apply as a translation + // to the view + view.setTranslationX(diff.left - diff.right); + view.setTranslationY(shouldTranslate ? diff.top - diff.bottom : -kbHeight); + + return insets; + } + + @Override + public void onEnd(@NonNull final WindowInsetsAnimationCompat animation) { + try { + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); + if (kbHeight == 0) { + if (rootWindowInsets == null) return; + final Insets imeInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.ime()); + final Insets navBarInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()); + kbHeight = imeInsets.bottom - navBarInsets.bottom; + final ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (layoutParams != null) { + layoutParams.height = kbHeight; + layoutParams.setMargins(layoutParams.leftMargin, layoutParams.topMargin, layoutParams.rightMargin, -kbHeight); + } + } + view.setTranslationX(0f); + final boolean visible = rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); + float translationY = 0; + if (!shouldTranslate) { + translationY = -kbHeight; + if (visible) { + translationY = 0; + } + } + view.setTranslationY(translationY); + + if (listener != null && rootWindowInsets != null) { + listener.onChange(visible); + } + } finally { + shouldTranslate = true; + } + } + + public void setShouldTranslate(final boolean shouldTranslate) { + this.shouldTranslate = shouldTranslate; + } + + public void setKbVisibilityListener(final onKbVisibilityChangeListener listener) { + this.listener = listener; + } + + public interface onKbVisibilityChangeListener { + void onChange(boolean isVisible); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/RootViewDeferringInsetsCallback.java b/app/src/main/java/awais/instagrabber/customviews/helpers/RootViewDeferringInsetsCallback.java new file mode 100644 index 00000000..f58be88a --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/RootViewDeferringInsetsCallback.java @@ -0,0 +1,139 @@ +package awais.instagrabber.customviews.helpers;/* + * Copyright 2020 The Android Open Source Project + * + * 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. + */ + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.graphics.Insets; +import androidx.core.view.OnApplyWindowInsetsListener; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsCompat; + +import java.util.List; + +/** + * A class which extends/implements both [WindowInsetsAnimationCompat.Callback] and + * [View.OnApplyWindowInsetsListener], which should be set on the root view in your layout. + *

+ * This class enables the root view is selectively defer handling any insets which match + * [deferredInsetTypes], to enable better looking [WindowInsetsAnimationCompat]s. + *

+ * An example is the following: when a [WindowInsetsAnimationCompat] is started, the system will dispatch + * a [WindowInsetsCompat] instance which contains the end state of the animation. For the scenario of + * the IME being animated in, that means that the insets contains the IME height. If the view's + * [View.OnApplyWindowInsetsListener] simply always applied the combination of + * [WindowInsetsCompat.Type.ime] and [WindowInsetsCompat.Type.systemBars] using padding, the viewport of any + * child views would then be smaller. This results in us animating a smaller (padded-in) view into + * a larger viewport. Visually, this results in the views looking clipped. + *

+ * This class allows us to implement a different strategy for the above scenario, by selectively + * deferring the [WindowInsetsCompat.Type.ime] insets until the [WindowInsetsAnimationCompat] is ended. + * For the above example, you would create a [RootViewDeferringInsetsCallback] like so: + *

+ * ``` + * val callback = RootViewDeferringInsetsCallback( + * persistentInsetTypes = WindowInsetsCompat.Type.systemBars(), + * deferredInsetTypes = WindowInsetsCompat.Type.ime() + * ) + * ``` + *

+ * This class is not limited to just IME animations, and can work with any [WindowInsetsCompat.Type]s. + */ +public class RootViewDeferringInsetsCallback extends WindowInsetsAnimationCompat.Callback implements OnApplyWindowInsetsListener { + + private final int persistentInsetTypes; + private final int deferredInsetTypes; + @Nullable + private View view = null; + @Nullable + private WindowInsetsCompat lastWindowInsets = null; + private boolean deferredInsets = false; + + /** + * @param persistentInsetTypes the bitmask of any inset types which should always be handled + * through padding the attached view + * @param deferredInsetTypes the bitmask of insets types which should be deferred until after + * any related [WindowInsetsAnimationCompat]s have ended + */ + public RootViewDeferringInsetsCallback(final int persistentInsetTypes, final int deferredInsetTypes) { + super(DISPATCH_MODE_CONTINUE_ON_SUBTREE); + if ((persistentInsetTypes & deferredInsetTypes) != 0) { + throw new IllegalArgumentException("persistentInsetTypes and deferredInsetTypes can not contain " + + "any of same WindowInsetsCompat.Type values"); + } + this.persistentInsetTypes = persistentInsetTypes; + this.deferredInsetTypes = deferredInsetTypes; + } + + @Override + public WindowInsetsCompat onApplyWindowInsets(@NonNull final View v, @NonNull final WindowInsetsCompat windowInsets) { + // Store the view and insets for us in onEnd() below + view = v; + lastWindowInsets = windowInsets; + + final int types = deferredInsets + // When the deferred flag is enabled, we only use the systemBars() insets + ? persistentInsetTypes + // Otherwise we handle the combination of the the systemBars() and ime() insets + : persistentInsetTypes | deferredInsetTypes; + + // Finally we apply the resolved insets by setting them as padding + final Insets typeInsets = windowInsets.getInsets(types); + v.setPadding(typeInsets.left, typeInsets.top, typeInsets.right, typeInsets.bottom); + + // We return the new WindowInsetsCompat.CONSUMED to stop the insets being dispatched any + // further into the view hierarchy. This replaces the deprecated + // WindowInsetsCompat.consumeSystemWindowInsets() and related functions. + return WindowInsetsCompat.CONSUMED; + } + + @Override + public void onPrepare(WindowInsetsAnimationCompat animation) { + if ((animation.getTypeMask() & deferredInsetTypes) != 0) { + // We defer the WindowInsetsCompat.Type.ime() insets if the IME is currently not visible. + // This results in only the WindowInsetsCompat.Type.systemBars() being applied, allowing + // the scrolling view to remain at it's larger size. + deferredInsets = true; + } + } + + @NonNull + @Override + public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, + @NonNull final List runningAnims) { + // This is a no-op. We don't actually want to handle any WindowInsetsAnimations + return insets; + } + + @Override + public void onEnd(@NonNull final WindowInsetsAnimationCompat animation) { + if (deferredInsets && (animation.getTypeMask() & deferredInsetTypes) != 0) { + // If we deferred the IME insets and an IME animation has finished, we need to reset + // the flag + deferredInsets = false; + + // And finally dispatch the deferred insets to the view now. + // Ideally we would just call view.requestApplyInsets() and let the normal dispatch + // cycle happen, but this happens too late resulting in a visual flicker. + // Instead we manually dispatch the most recent WindowInsets to the view. + if (lastWindowInsets != null && view != null) { + ViewCompat.dispatchApplyWindowInsets(view, lastWindowInsets); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/SimpleImeAnimationController.java b/app/src/main/java/awais/instagrabber/customviews/helpers/SimpleImeAnimationController.java new file mode 100644 index 00000000..9bcc24d8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/SimpleImeAnimationController.java @@ -0,0 +1,443 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 awais.instagrabber.customviews.helpers; + +import android.os.CancellationSignal; +import android.util.Log; +import android.view.View; +import android.view.animation.LinearInterpolator; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationControlListenerCompat; +import androidx.core.view.WindowInsetsAnimationControllerCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; +import androidx.dynamicanimation.animation.FloatPropertyCompat; +import androidx.dynamicanimation.animation.SpringAnimation; +import androidx.dynamicanimation.animation.SpringForce; + +import awais.instagrabber.utils.ViewUtils; + +/** + * A wrapper around the [WindowInsetsAnimationControllerCompat] APIs in AndroidX Core, to simplify + * the implementation of common use-cases around the IME. + *

+ * See [InsetsAnimationLinearLayout] and [InsetsAnimationTouchListener] for examples of how + * to use this class. + */ +public class SimpleImeAnimationController { + private static final String TAG = SimpleImeAnimationController.class.getSimpleName(); + /** + * Scroll threshold for determining whether to animating to the end state, or to the start state. + * Currently 15% of the total swipe distance distance + */ + private static final float SCROLL_THRESHOLD = 0.15f; + + @Nullable + private WindowInsetsAnimationControllerCompat insetsAnimationController = null; + @Nullable + private CancellationSignal pendingRequestCancellationSignal = null; + @Nullable + private OnRequestReadyListener pendingRequestOnReadyListener; + /** + * True if the IME was shown at the start of the current animation. + */ + private boolean isImeShownAtStart = false; + @Nullable + private SpringAnimation currentSpringAnimation = null; + private WindowInsetsAnimationControlListenerCompat fwdListener; + + /** + * A LinearInterpolator instance we can re-use across listeners. + */ + private final LinearInterpolator linearInterpolator = new LinearInterpolator(); + /* To take control of the an WindowInsetsAnimation, we need to pass in a listener to + controlWindowInsetsAnimation() in startControlRequest(). The listener created here + keeps track of the current WindowInsetsAnimationController and resets our state. */ + private final WindowInsetsAnimationControlListenerCompat animationControlListener = new WindowInsetsAnimationControlListenerCompat() { + /** + * Once the request is ready, call our [onRequestReady] function + */ + @Override + public void onReady(@NonNull final WindowInsetsAnimationControllerCompat controller, final int types) { + onRequestReady(controller); + if (fwdListener != null) { + fwdListener.onReady(controller, types); + } + } + + /** + * If the request is finished, we should reset our internal state + */ + @Override + public void onFinished(@NonNull final WindowInsetsAnimationControllerCompat controller) { + reset(); + if (fwdListener != null) { + fwdListener.onFinished(controller); + } + } + + /** + * If the request is cancelled, we should reset our internal state + */ + @Override + public void onCancelled(@Nullable final WindowInsetsAnimationControllerCompat controller) { + reset(); + if (fwdListener != null) { + fwdListener.onCancelled(controller); + } + } + }; + + /** + * Start a control request to the [view]s [android.view.WindowInsetsController]. This should + * be called once the view is in a position to take control over the position of the IME. + * + * @param view The view which is triggering this request + * @param onRequestReadyListener optional listener which will be called when the request is ready and + * the animation can proceed + */ + public void startControlRequest(@NonNull final View view, + @Nullable final OnRequestReadyListener onRequestReadyListener) { + if (isInsetAnimationInProgress()) { + Log.w(TAG, "startControlRequest: Animation in progress. Can not start a new request to controlWindowInsetsAnimation()"); + return; + } + + // Keep track of the IME insets, and the IME visibility, at the start of the request + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); + if (rootWindowInsets != null) { + isImeShownAtStart = rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); + } + + // Create a cancellation signal, which we pass to controlWindowInsetsAnimation() below + pendingRequestCancellationSignal = new CancellationSignal(); + // Keep reference to the onReady callback + pendingRequestOnReadyListener = onRequestReadyListener; + + // Finally we make a controlWindowInsetsAnimation() request: + final WindowInsetsControllerCompat windowInsetsController = ViewCompat.getWindowInsetsController(view); + if (windowInsetsController != null) { + windowInsetsController.controlWindowInsetsAnimation( + // We're only catering for IME animations in this listener + WindowInsetsCompat.Type.ime(), + // Animation duration. This is not used by the system, and is only passed to any + // WindowInsetsAnimation.Callback set on views. We pass in -1 to indicate that we're + // not starting a finite animation, and that this is completely controlled by + // the user's touch. + -1, + // The time interpolator used in calculating the animation progress. The fraction value + // we passed into setInsetsAndAlpha() which be passed into this interpolator before + // being used by the system to inset the IME. LinearInterpolator is a good type + // to use for scrolling gestures. + linearInterpolator, + // A cancellation signal, which allows us to cancel the request to control + pendingRequestCancellationSignal, + // The WindowInsetsAnimationControlListener + animationControlListener + ); + } + } + + /** + * Start a control request to the [view]s [android.view.WindowInsetsController], similar to + * [startControlRequest], but immediately fling to a finish using [velocityY] once ready. + *

+ * This function is useful for fire-and-forget operations to animate the IME. + * + * @param view The view which is triggering this request + * @param velocityY the velocity of the touch gesture which caused this call + */ + public void startAndFling(@NonNull final View view, final float velocityY) { + startControlRequest(view, null); + animateToFinish(velocityY); + } + + /** + * Update the inset position of the IME by the given [dy] value. This value will be coerced + * into the hidden and shown inset values. + *

+ * This function should only be called if [isInsetAnimationInProgress] returns true. + * + * @return the amount of [dy] consumed by the inset animation, in pixels + */ + public int insetBy(final int dy) { + if (insetsAnimationController == null) { + throw new IllegalStateException("Current WindowInsetsAnimationController is null." + + "This should only be called if isAnimationInProgress() returns true"); + } + final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; + + // Call updateInsetTo() with the new inset value + return insetTo(controller.getCurrentInsets().bottom - dy); + } + + /** + * Update the inset position of the IME to be the given [inset] value. This value will be + * coerced into the hidden and shown inset values. + *

+ * This function should only be called if [isInsetAnimationInProgress] returns true. + * + * @return the distance moved by the inset animation, in pixels + */ + public int insetTo(final int inset) { + if (insetsAnimationController == null) { + throw new IllegalStateException("Current WindowInsetsAnimationController is null." + + "This should only be called if isAnimationInProgress() returns true"); + } + final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; + + final int hiddenBottom = controller.getHiddenStateInsets().bottom; + final int shownBottom = controller.getShownStateInsets().bottom; + final int startBottom = isImeShownAtStart ? shownBottom : hiddenBottom; + final int endBottom = isImeShownAtStart ? hiddenBottom : shownBottom; + + // We coerce the given inset within the limits of the hidden and shown insets + final int coercedBottom = coerceIn(inset, hiddenBottom, shownBottom); + + final int consumedDy = controller.getCurrentInsets().bottom - coercedBottom; + + // Finally update the insets in the WindowInsetsAnimationController using + // setInsetsAndAlpha(). + controller.setInsetsAndAlpha( + // Here we update the animating insets. This is what controls where the IME is displayed. + // It is also passed through to views via their WindowInsetsAnimation.Callback. + Insets.of(0, 0, 0, coercedBottom), + // This controls the alpha value. We don't want to alter the alpha so use 1f + 1f, + // Finally we calculate the animation progress fraction. This value is passed through + // to any WindowInsetsAnimation.Callbacks, but it is not used by the system. + (coercedBottom - startBottom) / (float) (endBottom - startBottom) + ); + + return consumedDy; + } + + /** + * Return `true` if an inset animation is in progress. + */ + public boolean isInsetAnimationInProgress() { + return insetsAnimationController != null; + } + + /** + * Return `true` if an inset animation is currently finishing. + */ + public boolean isInsetAnimationFinishing() { + return currentSpringAnimation != null; + } + + /** + * Return `true` if a request to control an inset animation is in progress. + */ + public boolean isInsetAnimationRequestPending() { + return pendingRequestCancellationSignal != null; + } + + /** + * Cancel the current [WindowInsetsAnimationControllerCompat]. We immediately finish + * the animation, reverting back to the state at the start of the gesture. + */ + public void cancel() { + if (insetsAnimationController != null) { + insetsAnimationController.finish(isImeShownAtStart); + } + if (pendingRequestCancellationSignal != null) { + pendingRequestCancellationSignal.cancel(); + } + if (currentSpringAnimation != null) { + // Cancel the current spring animation + currentSpringAnimation.cancel(); + } + reset(); + } + + /** + * Finish the current [WindowInsetsAnimationControllerCompat] immediately. + */ + public void finish() { + final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; + + if (controller == null) { + // If we don't currently have a controller, cancel any pending request and return + if (pendingRequestCancellationSignal != null) { + pendingRequestCancellationSignal.cancel(); + } + return; + } + + final int current = controller.getCurrentInsets().bottom; + final int shown = controller.getShownStateInsets().bottom; + final int hidden = controller.getHiddenStateInsets().bottom; + + // The current inset matches either the shown/hidden inset, finish() immediately + if (current == shown) { + controller.finish(true); + } else if (current == hidden) { + controller.finish(false); + } else { + // Otherwise, we'll look at the current position... + if (controller.getCurrentFraction() >= SCROLL_THRESHOLD) { + // If the IME is past the 'threshold' we snap to the toggled state + controller.finish(!isImeShownAtStart); + } else { + // ...otherwise, we snap back to the original visibility + controller.finish(isImeShownAtStart); + } + } + } + + /** + * Finish the current [WindowInsetsAnimationControllerCompat]. We finish the animation, + * animating to the end state if necessary. + * + * @param velocityY the velocity of the touch gesture which caused this call to [animateToFinish]. + * Can be `null` if velocity is not available. + */ + public void animateToFinish(@Nullable final Float velocityY) { + final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; + + if (controller == null) { + // If we don't currently have a controller, cancel any pending request and return + if (pendingRequestCancellationSignal != null) { + pendingRequestCancellationSignal.cancel(); + } + return; + } + + final int current = controller.getCurrentInsets().bottom; + final int shown = controller.getShownStateInsets().bottom; + final int hidden = controller.getHiddenStateInsets().bottom; + + if (velocityY != null) { + // If we have a velocity, we can use it's direction to determine + // the visibility. Upwards == visible + animateImeToVisibility(velocityY > 0, velocityY); + } else if (current == shown) { + // The current inset matches either the shown/hidden inset, finish() immediately + controller.finish(true); + } else if (current == hidden) { + controller.finish(false); + } else { + // Otherwise, we'll look at the current position... + if (controller.getCurrentFraction() >= SCROLL_THRESHOLD) { + // If the IME is past the 'threshold' we animate it to the toggled state + animateImeToVisibility(!isImeShownAtStart, null); + } else { + // ...otherwise, we animate it back to the original visibility + animateImeToVisibility(isImeShownAtStart, null); + } + } + } + + private void onRequestReady(@NonNull final WindowInsetsAnimationControllerCompat controller) { + // The request is ready, so clear out the pending cancellation signal + pendingRequestCancellationSignal = null; + // Store the current WindowInsetsAnimationController + insetsAnimationController = controller; + + // Call any pending callback + if (pendingRequestOnReadyListener != null) { + pendingRequestOnReadyListener.onRequestReady(controller); + } + pendingRequestOnReadyListener = null; + } + + /** + * Resets all of our internal state. + */ + private void reset() { + // Clear all of our internal state + insetsAnimationController = null; + pendingRequestCancellationSignal = null; + isImeShownAtStart = false; + if (currentSpringAnimation != null) { + currentSpringAnimation.cancel(); + } + currentSpringAnimation = null; + pendingRequestOnReadyListener = null; + } + + /** + * Animate the IME to a given visibility. + * + * @param visible `true` to animate the IME to it's fully shown state, `false` to it's + * fully hidden state. + * @param velocityY the velocity of the touch gesture which caused this call. Can be `null` + * if velocity is not available. + */ + private void animateImeToVisibility(final boolean visible, @Nullable final Float velocityY) { + if (insetsAnimationController == null) { + throw new IllegalStateException("Controller should not be null"); + } + final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; + + final FloatPropertyCompat property = new FloatPropertyCompat("property") { + @Override + public float getValue(final Object object) { + return controller.getCurrentInsets().bottom; + } + + @Override + public void setValue(final Object object, final float value) { + if (insetsAnimationController == null) { + return; + } + insetTo((int) value); + } + }; + final float finalPosition = visible ? controller.getShownStateInsets().bottom + : controller.getHiddenStateInsets().bottom; + final SpringForce force = new SpringForce(finalPosition) + // Tweak the damping value, to remove any bounciness. + .setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY) + // The stiffness value controls the strength of the spring animation, which + // controls the speed. Medium (the default) is a good value, but feel free to + // play around with this value. + .setStiffness(SpringForce.STIFFNESS_MEDIUM); + ViewUtils.springAnimationOf(this, property, finalPosition) + .setSpring(force) + .setStartVelocity(velocityY != null ? velocityY : 0) + .addEndListener((animation, canceled, value, velocity) -> { + if (animation == currentSpringAnimation) { + currentSpringAnimation = null; + } + // Once the animation has ended, finish the controller + finish(); + }).start(); + } + + private int coerceIn(final int v, final int min, final int max) { + if (v >= min && v <= max) { + return v; + } + if (v < min) { + return min; + } + return max; + } + + public void setAnimationControlListener(final WindowInsetsAnimationControlListenerCompat listener) { + fwdListener = listener; + } + + public interface OnRequestReadyListener { + void onRequestReady(WindowInsetsAnimationControllerCompat windowInsetsAnimationControllerCompat); + } +} diff --git a/app/src/main/java/awais/instagrabber/customviews/helpers/TranslateDeferringInsetsAnimationCallback.java b/app/src/main/java/awais/instagrabber/customviews/helpers/TranslateDeferringInsetsAnimationCallback.java new file mode 100644 index 00000000..e10105ea --- /dev/null +++ b/app/src/main/java/awais/instagrabber/customviews/helpers/TranslateDeferringInsetsAnimationCallback.java @@ -0,0 +1,128 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 awais.instagrabber.customviews.helpers; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsCompat; + +import java.util.List; + +/** + * A [WindowInsetsAnimationCompat.Callback] which will translate/move the given view during any + * inset animations of the given inset type. + *

+ * This class works in tandem with [RootViewDeferringInsetsCallback] to support the deferring of + * certain [WindowInsetsCompat.Type] values during a [WindowInsetsAnimationCompat], provided in + * [deferredInsetTypes]. The values passed into this constructor should match those which + * the [RootViewDeferringInsetsCallback] is created with. + */ +public class TranslateDeferringInsetsAnimationCallback extends WindowInsetsAnimationCompat.Callback { + private final View view; + private final int persistentInsetTypes; + private final int deferredInsetTypes; + + private boolean shouldTranslate = true; + private int kbHeight; + + public TranslateDeferringInsetsAnimationCallback(final View view, + final int persistentInsetTypes, + final int deferredInsetTypes) { + this(view, persistentInsetTypes, deferredInsetTypes, DISPATCH_MODE_STOP); + } + + /** + * @param view the view to translate from it's start to end state + * @param persistentInsetTypes the bitmask of any inset types which were handled as part of the + * layout + * @param deferredInsetTypes the bitmask of insets types which should be deferred until after + * any [WindowInsetsAnimationCompat]s have ended + * @param dispatchMode The dispatch mode for this callback. + * See [WindowInsetsAnimationCompat.Callback.getDispatchMode]. + */ + public TranslateDeferringInsetsAnimationCallback(final View view, + final int persistentInsetTypes, + final int deferredInsetTypes, + final int dispatchMode) { + super(dispatchMode); + if ((persistentInsetTypes & deferredInsetTypes) != 0) { + throw new IllegalArgumentException("persistentInsetTypes and deferredInsetTypes can not contain " + + "any of same WindowInsetsCompat.Type values"); + } + this.view = view; + this.persistentInsetTypes = persistentInsetTypes; + this.deferredInsetTypes = deferredInsetTypes; + } + + @NonNull + @Override + public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, + @NonNull final List runningAnimations) { + // onProgress() is called when any of the running animations progress... + + // First we get the insets which are potentially deferred + final Insets typesInset = insets.getInsets(deferredInsetTypes); + // Then we get the persistent inset types which are applied as padding during layout + final Insets otherInset = insets.getInsets(persistentInsetTypes); + + // Now that we subtract the two insets, to calculate the difference. We also coerce + // the insets to be >= 0, to make sure we don't use negative insets. + final Insets subtract = Insets.subtract(typesInset, otherInset); + final Insets diff = Insets.max(subtract, Insets.NONE); + + // The resulting `diff` insets contain the values for us to apply as a translation + // to the view + view.setTranslationX(diff.left - diff.right); + view.setTranslationY(shouldTranslate ? diff.top - diff.bottom : -kbHeight); + + return insets; + } + + @Override + public void onEnd(@NonNull final WindowInsetsAnimationCompat animation) { + try { + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); + if (kbHeight == 0) { + if (rootWindowInsets == null) return; + final Insets imeInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.ime()); + final Insets navBarInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()); + kbHeight = imeInsets.bottom - navBarInsets.bottom; + } + // Once the animation has ended, reset the translation values + view.setTranslationX(0f); + final boolean visible = rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); + float translationY = 0; + if (!shouldTranslate) { + translationY = -kbHeight; + if (visible) { + translationY = 0; + } + } + view.setTranslationY(translationY); + } finally { + shouldTranslate = true; + } + } + + public void setShouldTranslate(final boolean shouldTranslate) { + this.shouldTranslate = shouldTranslate; + } +} diff --git a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java index 1d6b8f6e..99d72a57 100644 --- a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java @@ -15,6 +15,7 @@ import android.net.Uri; import android.os.Bundle; import android.text.Editable; import android.util.Log; +import android.util.Pair; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; @@ -25,11 +26,17 @@ import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; import android.widget.Toast; +import androidx.activity.OnBackPressedCallback; +import androidx.activity.OnBackPressedDispatcher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; -import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsAnimationCompat; +import androidx.core.view.WindowInsetsAnimationControlListenerCompat; +import androidx.core.view.WindowInsetsAnimationControllerCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; @@ -70,16 +77,21 @@ import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemOrHeader; import awais.instagrabber.adapters.DirectReactionsAdapter; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemViewHolder; import awais.instagrabber.animations.CubicBezierInterpolator; +import awais.instagrabber.customviews.InsetsAnimationLinearLayout; +import awais.instagrabber.customviews.KeyNotifyingEmojiEditText; import awais.instagrabber.customviews.RecordView; import awais.instagrabber.customviews.Tooltip; import awais.instagrabber.customviews.emoji.Emoji; import awais.instagrabber.customviews.emoji.EmojiBottomSheetDialog; import awais.instagrabber.customviews.emoji.EmojiPicker; +import awais.instagrabber.customviews.helpers.ControlFocusInsetsAnimationCallback; +import awais.instagrabber.customviews.helpers.EmojiPickerInsetsAnimationCallback; import awais.instagrabber.customviews.helpers.HeaderItemDecoration; -import awais.instagrabber.customviews.helpers.HeightProvider; import awais.instagrabber.customviews.helpers.RecyclerLazyLoaderAtEdge; +import awais.instagrabber.customviews.helpers.SimpleImeAnimationController; import awais.instagrabber.customviews.helpers.SwipeAndRestoreItemTouchHelperCallback; import awais.instagrabber.customviews.helpers.TextWatcherAdapter; +import awais.instagrabber.customviews.helpers.TranslateDeferringInsetsAnimationCallback; import awais.instagrabber.databinding.FragmentDirectMessagesThreadBinding; import awais.instagrabber.dialogs.DirectItemReactionDialogFragment; import awais.instagrabber.dialogs.GifPickerBottomDialogFragment; @@ -111,9 +123,6 @@ import awais.instagrabber.viewmodels.AppStateViewModel; import awais.instagrabber.viewmodels.DirectThreadViewModel; import awais.instagrabber.viewmodels.factories.DirectThreadViewModelFactory; -import static android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING; -import static android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN; - public class DirectMessageThreadFragment extends Fragment implements DirectReactionsAdapter.OnReactionClickListener, EmojiPicker.OnEmojiClickListener { private static final String TAG = DirectMessageThreadFragment.class.getSimpleName(); @@ -125,7 +134,7 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact private DirectItemsAdapter itemsAdapter; private MainActivity fragmentActivity; private DirectThreadViewModel viewModel; - private ConstraintLayout root; + private InsetsAnimationLinearLayout root; private boolean shouldRefresh = true; private List itemOrHeaders; private List users; @@ -135,14 +144,8 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact private ActionBar actionBar; private AppStateViewModel appStateViewModel; private Runnable prevTitleRunnable; - private int originalSoftInputMode; private AnimatorSet animatorSet; - private boolean isEmojiPickerShown; - private boolean isKbShown; - private HeightProvider heightProvider; private boolean isRecording; - private boolean wasKbShowing; - private int keyboardHeight = Utils.convertDpToPx(250); private DirectItemReactionDialogFragment reactionDialogFragment; private DirectItem itemToForward; private MutableLiveData backStackSavedStateResultLiveData; @@ -163,6 +166,11 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact private MenuItem markAsSeenMenuItem; private Media tempMedia; private DirectItem addReactionItem; + private TranslateDeferringInsetsAnimationCallback inputHolderAnimationCallback; + private TranslateDeferringInsetsAnimationCallback chatsAnimationCallback; + private EmojiPickerInsetsAnimationCallback emojiPickerAnimationCallback; + private boolean hasKbOpenedOnce; + private boolean wasToggled; private final AppExecutors appExecutors = AppExecutors.getInstance(); private final Animatable2Compat.AnimationCallback micToSendAnimationCallback = new Animatable2Compat.AnimationCallback() { @@ -304,7 +312,6 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact emojiBottomSheetDialog.show(getChildFragmentManager(), EmojiBottomSheetDialog.TAG); } }; - private final DirectItemLongClickListener directItemLongClickListener = position -> { // viewModel.setSelectedPosition(position); }; @@ -333,6 +340,14 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact backStackSavedStateResultLiveData.postValue(null); }; private final MutableLiveData inputLength = new MutableLiveData<>(0); + private final MutableLiveData emojiPickerVisible = new MutableLiveData<>(false); + private final MutableLiveData kbVisible = new MutableLiveData<>(false); + private final OnBackPressedCallback onEmojiPickerBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + emojiPickerVisible.postValue(false); + } + }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { @@ -371,13 +386,13 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact return root; } tooltip = new Tooltip(context, root, getResources().getColor(R.color.grey_400), getResources().getColor(R.color.black)); - originalSoftInputMode = fragmentActivity.getWindow().getAttributes().softInputMode; // todo check has camera and remove view return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + // WindowCompat.setDecorFitsSystemWindows(fragmentActivity.getWindow(), false); if (!shouldRefresh) return; init(); binding.send.post(() -> initialSendX = binding.send.getX()); @@ -490,10 +505,11 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact if (isRecording) { binding.recordView.cancelRecording(binding.send); } - if (isKbShown) { - wasKbShowing = true; - binding.emojiPicker.setAlpha(0); - } + emojiPickerVisible.postValue(false); + kbVisible.postValue(false); + binding.inputHolder.setTranslationY(0); + binding.chats.setTranslationY(0); + binding.emojiPicker.setTranslationY(0); removeObservers(); super.onPause(); } @@ -501,16 +517,12 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact @Override public void onResume() { super.onResume(); - fragmentActivity.getWindow().setSoftInputMode(SOFT_INPUT_ADJUST_NOTHING | SOFT_INPUT_STATE_HIDDEN); - if (wasKbShowing) { - binding.input.requestFocus(); - binding.input.post(this::showKeyboard); - wasKbShowing = false; - } if (initialSendX != 0) { binding.send.setX(initialSendX); } binding.send.stopScale(); + final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); + onBackPressedDispatcher.addCallback(onEmojiPickerBackPressedCallback); setupBackStackResultObserver(); setObservers(); // attachPendingRequestsBadge(viewModel.getPendingRequestsCount().getValue()); @@ -533,13 +545,6 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact if (prevTitleRunnable != null) { appExecutors.mainThread().cancel(prevTitleRunnable); } - if (heightProvider != null) { - // need to close the height provider popup before navigating back to prevent leak - heightProvider.dismiss(); - } - if (originalSoftInputMode != 0) { - fragmentActivity.getWindow().setSoftInputMode(originalSoftInputMode); - } for (int childCount = binding.chats.getChildCount(), i = 0; i < childCount; ++i) { final RecyclerView.ViewHolder holder = binding.chats.getChildViewHolder(binding.chats.getChildAt(i)); if (holder == null) continue; @@ -561,37 +566,8 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact actionBar = fragmentActivity.getSupportActionBar(); setupList(); root.post(this::setupInput); - // root.post(this::getInitialData); } - // private void getInitialData() { - // final Bundle arguments = getArguments(); - // if (arguments == null) return; - // final DirectMessageThreadFragmentArgs args = DirectMessageThreadFragmentArgs.fromBundle(arguments); - // final boolean pending = args.getPending(); - // final NavController navController = NavHostFragment.findNavController(this); - // final ViewModelStoreOwner viewModelStoreOwner = navController.getViewModelStoreOwner(R.id.direct_messages_nav_graph); - // final List threads; - // if (!pending) { - // final DirectInboxViewModel threadListViewModel = new ViewModelProvider(viewModelStoreOwner).get(DirectInboxViewModel.class); - // threads = threadListViewModel.getThreads().getValue(); - // } else { - // final DirectPendingInboxViewModel threadListViewModel = new ViewModelProvider(viewModelStoreOwner).get(DirectPendingInboxViewModel.class); - // threads = threadListViewModel.getThreads().getValue(); - // } - // final Optional first = threads != null - // ? threads.stream() - // .filter(thread -> thread.getThreadId().equals(viewModel.getThreadId())) - // .findFirst() - // : Optional.empty(); - // if (first.isPresent()) { - // final DirectThread thread = first.get(); - // viewModel.setThread(thread); - // return; - // } - // viewModel.fetchChats(); - // } - private void setupList() { final Context context = getContext(); if (context == null) return; @@ -1100,23 +1076,6 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { final int length = s.length(); inputLength.postValue(length); - // boolean showExtraInputOptionsChanged = false; - // if (prevLength != 0 && length == 0) { - // inputLength.postValue(true); - // showExtraInputOptionsChanged = true; - // binding.send.setListenForRecord(true); - // startIconAnimation(); - // } - // if (prevLength == 0 && length != 0) { - // inputLength.postValue(false); - // showExtraInputOptionsChanged = true; - // binding.send.setListenForRecord(false); - // startIconAnimation(); - // } - // if (!showExtraInputOptionsChanged) { - // showExtraInputOptions.postValue(length == 0); - // } - // prevLength = length; } }); binding.send.setOnRecordClickListener(v -> { @@ -1131,30 +1090,15 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact Log.d(TAG, "setOnRecordLongClickListener"); return true; }); - binding.input.setShowSoftInputOnFocus(false); - binding.input.requestFocus(); - binding.input.setOnKeyEventListener((keyCode, keyEvent) -> { - if (keyCode != KeyEvent.KEYCODE_BACK) return false; - // We'll close the keyboard/emoji picker only when user releases the back button - // return true so that system doesn't handle the event - if (keyEvent.getAction() != KeyEvent.ACTION_UP) return true; - if (!isKbShown && !isEmojiPickerShown) { - // if both keyboard and emoji picker are hidden, navigate back - if (heightProvider != null) { - // need to close the height provider popup before navigating back to prevent leak - heightProvider.dismiss(); - } - NavHostFragment.findNavController(this).navigateUp(); - return true; - } - binding.emojiToggle.setIconResource(R.drawable.ic_face_24); - hideKeyboard(true); - return true; - }); - binding.input.setOnClickListener(v -> { - if (isKbShown) return; - showKeyboard(); + binding.input.setOnFocusChangeListener((v, hasFocus) -> { + if (!hasFocus) return; + final Boolean emojiPickerVisibleValue = emojiPickerVisible.getValue(); + if (emojiPickerVisibleValue == null || !emojiPickerVisibleValue) return; + inputHolderAnimationCallback.setShouldTranslate(false); + chatsAnimationCallback.setShouldTranslate(false); + emojiPickerAnimationCallback.setShouldTranslate(false); }); + setupInsetsCallback(); setupEmojiPicker(); binding.gallery.setOnClickListener(v -> { final MediaPickerBottomDialogFragment mediaPicker = MediaPickerBottomDialogFragment.newInstance(); @@ -1166,7 +1110,6 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact } }); mediaPicker.show(getChildFragmentManager(), "MediaPicker"); - hideKeyboard(true); }); binding.gif.setOnClickListener(v -> { final GifPickerBottomDialogFragment gifPicker = GifPickerBottomDialogFragment.newInstance(); @@ -1176,7 +1119,6 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact handleSentMessage(viewModel.sendAnimatedMedia(giphyGif)); }); gifPicker.show(getChildFragmentManager(), "GifPicker"); - hideKeyboard(true); }); binding.camera.setOnClickListener(v -> { final Intent intent = new Intent(context, CameraActivity.class); @@ -1184,6 +1126,73 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact }); } + private void setupInsetsCallback() { + inputHolderAnimationCallback = new TranslateDeferringInsetsAnimationCallback( + binding.inputHolder, + WindowInsetsCompat.Type.systemBars(), + WindowInsetsCompat.Type.ime(), + WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE + ); + ViewCompat.setWindowInsetsAnimationCallback(binding.inputHolder, inputHolderAnimationCallback); + chatsAnimationCallback = new TranslateDeferringInsetsAnimationCallback( + binding.chats, + WindowInsetsCompat.Type.systemBars(), + WindowInsetsCompat.Type.ime() + ); + ViewCompat.setWindowInsetsAnimationCallback(binding.chats, chatsAnimationCallback); + emojiPickerAnimationCallback = new EmojiPickerInsetsAnimationCallback( + binding.emojiPicker, + WindowInsetsCompat.Type.systemBars(), + WindowInsetsCompat.Type.ime() + ); + emojiPickerAnimationCallback.setKbVisibilityListener(this::onKbVisibilityChange); + ViewCompat.setWindowInsetsAnimationCallback(binding.emojiPicker, emojiPickerAnimationCallback); + ViewCompat.setWindowInsetsAnimationCallback( + binding.input, + new ControlFocusInsetsAnimationCallback(binding.input) + ); + final SimpleImeAnimationController imeAnimController = root.getImeAnimController(); + if (imeAnimController != null) { + imeAnimController.setAnimationControlListener(new WindowInsetsAnimationControlListenerCompat() { + @Override + public void onReady(@NonNull final WindowInsetsAnimationControllerCompat controller, final int types) {} + + @Override + public void onFinished(@NonNull final WindowInsetsAnimationControllerCompat controller) { + checkKbVisibility(); + } + + @Override + public void onCancelled(@Nullable final WindowInsetsAnimationControllerCompat controller) { + checkKbVisibility(); + } + + private void checkKbVisibility() { + final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(binding.getRoot()); + final boolean visible = rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); + onKbVisibilityChange(visible); + } + }); + } + } + + private void onKbVisibilityChange(final boolean kbVisible) { + this.kbVisible.postValue(kbVisible); + if (wasToggled) { + emojiPickerVisible.postValue(!kbVisible); + wasToggled = false; + return; + } + final Boolean emojiPickerVisibleValue = emojiPickerVisible.getValue(); + if (kbVisible && emojiPickerVisibleValue != null && emojiPickerVisibleValue) { + emojiPickerVisible.postValue(false); + return; + } + if (!kbVisible) { + emojiPickerVisible.postValue(false); + } + } + private void startIconAnimation() { final Drawable icon = binding.send.getIcon(); if (icon instanceof Animatable) { @@ -1230,15 +1239,87 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact private void setupEmojiPicker() { root.post(() -> binding.emojiPicker.init( root, - (view, emoji) -> binding.input.append(emoji.getUnicode()), + (view, emoji) -> { + final KeyNotifyingEmojiEditText input = binding.input; + final int start = input.getSelectionStart(); + final int end = input.getSelectionEnd(); + if (start < 0) { + input.append(emoji.getUnicode()); + return; + } + input.getText().replace( + Math.min(start, end), + Math.max(start, end), + emoji.getUnicode(), + 0, + emoji.getUnicode().length() + ); + }, () -> binding.input.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) )); - setupKbHeightProvider(); - if (keyboardHeight == 0) { - keyboardHeight = Utils.convertDpToPx(250); - } - setEmojiPickerBounds(); - binding.emojiToggle.setOnClickListener(v -> toggleEmojiPicker()); + binding.emojiToggle.setOnClickListener(v -> { + Boolean isEmojiPickerVisible = emojiPickerVisible.getValue(); + if (isEmojiPickerVisible == null) isEmojiPickerVisible = false; + Boolean isKbVisible = kbVisible.getValue(); + if (isKbVisible == null) isKbVisible = false; + wasToggled = isEmojiPickerVisible || isKbVisible; + + if (isEmojiPickerVisible) { + if (hasKbOpenedOnce && binding.emojiPicker.getTranslationY() != 0) { + inputHolderAnimationCallback.setShouldTranslate(false); + chatsAnimationCallback.setShouldTranslate(false); + emojiPickerAnimationCallback.setShouldTranslate(false); + } + // trigger ime. + // Since the kb visibility listener will toggle the emojiPickerVisible live data, we do not explicitly toggle it here + showKeyboard(); + return; + } + + if (isKbVisible) { + // hide the keyboard, but don't translate the views + // Since the kb visibility listener will toggle the emojiPickerVisible live data, we do not explicitly toggle it here + inputHolderAnimationCallback.setShouldTranslate(false); + chatsAnimationCallback.setShouldTranslate(false); + emojiPickerAnimationCallback.setShouldTranslate(false); + hideKeyboard(); + } + emojiPickerVisible.postValue(true); + }); + final LiveData> emojiKbVisibilityLD = Utils.zipLiveData(emojiPickerVisible, kbVisible); + emojiKbVisibilityLD.observe(getViewLifecycleOwner(), pair -> { + Boolean isEmojiPickerVisible = pair.first; + Boolean isKbVisible = pair.second; + if (isEmojiPickerVisible == null) isEmojiPickerVisible = false; + if (isKbVisible == null) isKbVisible = false; + root.setScrollImeOffScreenWhenVisible(!isEmojiPickerVisible); + root.setScrollImeOnScreenWhenNotVisible(!isEmojiPickerVisible); + onEmojiPickerBackPressedCallback.setEnabled(isEmojiPickerVisible && !isKbVisible); + if (isEmojiPickerVisible && !isKbVisible) { + animatePan(binding.emojiPicker.getMeasuredHeight(), unused -> { + binding.emojiPicker.setAlpha(1); + binding.emojiToggle.setIconResource(R.drawable.ic_keyboard_24); + return null; + }, null); + return; + } + if (!isEmojiPickerVisible && !isKbVisible) { + animatePan(0, null, unused -> { + binding.emojiPicker.setAlpha(0); + binding.emojiToggle.setIconResource(R.drawable.ic_face_24); + return null; + }); + return; + } + // isKbVisible will always be true going forward + hasKbOpenedOnce = true; + if (!isEmojiPickerVisible) { + binding.emojiToggle.setIconResource(R.drawable.ic_face_24); + binding.emojiPicker.setAlpha(0); + return; + } + binding.emojiPicker.setAlpha(1); + }); } public void showKeyboard() { @@ -1246,67 +1327,21 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact if (context == null) return; final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); if (imm == null) return; - if (!isEmojiPickerShown) { - binding.emojiPicker.setAlpha(0); + if (!binding.input.isFocused()) { + binding.input.requestFocus(); } final boolean shown = imm.showSoftInput(binding.input, InputMethodManager.SHOW_IMPLICIT); if (!shown) { Log.e(TAG, "showKeyboard: System did not display the keyboard"); } - if (!isEmojiPickerShown) { - animatePan(keyboardHeight); - } - isKbShown = true; } - public void hideKeyboard(final boolean shouldPan) { + public void hideKeyboard() { final Context context = getContext(); if (context == null) return; final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); if (imm == null) return; - if (shouldPan) { - binding.emojiPicker.setAlpha(0); - } imm.hideSoftInputFromWindow(binding.input.getWindowToken(), InputMethodManager.RESULT_UNCHANGED_SHOWN); - if (shouldPan) { - animatePan(0); - isEmojiPickerShown = false; - binding.emojiToggle.setIconResource(R.drawable.ic_face_24); - } - isKbShown = false; - } - - /** - * Toggle between emoji picker and keyboard - * If both are hidden, the emoji picker is shown first - */ - private void toggleEmojiPicker() { - if (isKbShown) { - binding.emojiToggle.setIconResource(R.drawable.ic_keyboard_24); - hideKeyboard(false); - return; - } - if (isEmojiPickerShown) { - binding.emojiToggle.setIconResource(R.drawable.ic_face_24); - showKeyboard(); - return; - } - binding.emojiToggle.setIconResource(R.drawable.ic_keyboard_24); - animatePan(keyboardHeight); - isEmojiPickerShown = true; - } - - /** - * Set height of the emoji picker - */ - private void setEmojiPickerBounds() { - final ViewGroup.LayoutParams layoutParams = binding.emojiPicker.getLayoutParams(); - layoutParams.height = keyboardHeight; - if (!isEmojiPickerShown) { - // If emoji picker is hidden reset the translationY so that it doesn't peek from bottom - binding.emojiPicker.setTranslationY(keyboardHeight); - } - binding.emojiPicker.requestLayout(); } private void setSendToMicIcon() { @@ -1375,40 +1410,18 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact return null; } - private void setupKbHeightProvider() { - if (heightProvider != null) return; - heightProvider = new HeightProvider(fragmentActivity).init().setHeightListener(height -> { - if (height > 100 && keyboardHeight != height) { - // save the current keyboard height to settings to use later - keyboardHeight = height; - setEmojiPickerBounds(); - animatePan(keyboardHeight); - } - }); - } - // Sets the translationY of views to height with animation - private void animatePan(final int height) { + private void animatePan(final int height, + @Nullable final Function onAnimationStart, + @Nullable final Function onAnimationEnd) { if (animatorSet != null && animatorSet.isStarted()) { animatorSet.cancel(); } final ImmutableList.Builder builder = ImmutableList.builder(); builder.add( ObjectAnimator.ofFloat(binding.chats, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.input, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.inputBg, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.recordView, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.emojiToggle, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.gif, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.gallery, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.camera, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.send, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.replyBg, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.replyInfo, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.replyCancel, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.replyPreviewImage, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.replyPreviewText, TRANSLATION_Y, -height), - ObjectAnimator.ofFloat(binding.emojiPicker, TRANSLATION_Y, keyboardHeight - height) + ObjectAnimator.ofFloat(binding.inputHolder, TRANSLATION_Y, -height), + ObjectAnimator.ofFloat(binding.emojiPicker, TRANSLATION_Y, -height) ); // if (headerItemDecoration != null && headerItemDecoration.getCurrentHeader() != null) { // builder.add(ObjectAnimator.ofFloat(headerItemDecoration.getCurrentHeader(), TRANSLATION_Y, height)); @@ -1418,10 +1431,21 @@ public class DirectMessageThreadFragment extends Fragment implements DirectReact animatorSet.setDuration(200); animatorSet.setInterpolator(CubicBezierInterpolator.EASE_IN); animatorSet.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(final Animator animation) { + super.onAnimationStart(animation); + if (onAnimationStart != null) { + onAnimationStart.apply(null); + } + } + @Override public void onAnimationEnd(final Animator animation) { - binding.emojiPicker.setAlpha(1); + super.onAnimationEnd(animation); animatorSet = null; + if (onAnimationEnd != null) { + onAnimationEnd.apply(null); + } } }); animatorSet.start(); diff --git a/app/src/main/java/awais/instagrabber/utils/Utils.java b/app/src/main/java/awais/instagrabber/utils/Utils.java index a9424b33..3a157f1a 100644 --- a/app/src/main/java/awais/instagrabber/utils/Utils.java +++ b/app/src/main/java/awais/instagrabber/utils/Utils.java @@ -38,6 +38,8 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat; import com.google.android.exoplayer2.database.ExoDatabaseProvider; @@ -562,4 +564,39 @@ public final class Utils { display.getRealSize(size); return size; } + + public static LiveData> zipLiveData(@NonNull final LiveData firstLiveData, + @NonNull final LiveData secondLiveData) { + final ZippedLiveData zippedLiveData = new ZippedLiveData<>(); + zippedLiveData.addFirstSource(firstLiveData); + zippedLiveData.addSecondSource(secondLiveData); + return zippedLiveData; + } + + public static class ZippedLiveData extends MediatorLiveData> { + private F lastF; + private S lastS; + + private void update() { + F localLastF = lastF; + S localLastS = lastS; + if (localLastF != null && localLastS != null) { + setValue(new Pair<>(localLastF, localLastS)); + } + } + + public void addFirstSource(@NonNull final LiveData firstLiveData) { + addSource(firstLiveData, f -> { + lastF = f; + update(); + }); + } + + public void addSecondSource(@NonNull final LiveData secondLiveData) { + addSource(secondLiveData, s -> { + lastS = s; + update(); + }); + } + } } diff --git a/app/src/main/java/awais/instagrabber/utils/ViewUtils.java b/app/src/main/java/awais/instagrabber/utils/ViewUtils.java index bf39d7a4..3e371045 100644 --- a/app/src/main/java/awais/instagrabber/utils/ViewUtils.java +++ b/app/src/main/java/awais/instagrabber/utils/ViewUtils.java @@ -1,18 +1,28 @@ package awais.instagrabber.utils; +import android.annotation.SuppressLint; import android.content.Context; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.ShapeDrawable; import android.graphics.drawable.shapes.RoundRectShape; +import android.os.Build; import android.view.View; +import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.TextView; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.content.res.ResourcesCompat; import androidx.core.util.Pair; +import androidx.dynamicanimation.animation.FloatPropertyCompat; +import androidx.dynamicanimation.animation.SpringAnimation; + +import org.jetbrains.annotations.NotNull; + +import kotlin.jvm.internal.Intrinsics; public final class ViewUtils { @@ -69,4 +79,45 @@ public final class ViewUtils { public static float getTextViewValueWidth(final TextView textView, final String text) { return textView.getPaint().measureText(text); } + + /** + * Creates [SpringAnimation] for object. + * If finalPosition is not [Float.NaN] then create [SpringAnimation] with + * [SpringForce.mFinalPosition]. + * + * @param object Object + * @param property object's property to be animated. + * @param finalPosition [SpringForce.mFinalPosition] Final position of spring. + * @return [SpringAnimation] + */ + @NonNull + public static SpringAnimation springAnimationOf(final Object object, + final FloatPropertyCompat property, + @Nullable final Float finalPosition) { + return finalPosition == null ? new SpringAnimation(object, property) : new SpringAnimation(object, property, finalPosition); + } + + public static void suppressLayoutCompat(@NotNull ViewGroup $this$suppressLayoutCompat, boolean suppress) { + Intrinsics.checkNotNullParameter($this$suppressLayoutCompat, "$this$suppressLayoutCompat"); + if (Build.VERSION.SDK_INT >= 29) { + $this$suppressLayoutCompat.suppressLayout(suppress); + } else { + hiddenSuppressLayout($this$suppressLayoutCompat, suppress); + } + + } + + private static boolean tryHiddenSuppressLayout = true; + + @SuppressLint({"NewApi"}) + private static void hiddenSuppressLayout(ViewGroup group, boolean suppress) { + if (tryHiddenSuppressLayout) { + try { + group.suppressLayout(suppress); + } catch (NoSuchMethodError var3) { + tryHiddenSuppressLayout = false; + } + } + + } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 05485643..9d1e880a 100755 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,5 @@ - @@ -71,4 +72,4 @@ android:layout_height="wrap_content" android:layout_gravity="bottom" app:labelVisibilityMode="auto" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_direct_messages_thread.xml b/app/src/main/res/layout/fragment_direct_messages_thread.xml index 96b0bc34..50120027 100644 --- a/app/src/main/res/layout/fragment_direct_messages_thread.xml +++ b/app/src/main/res/layout/fragment_direct_messages_thread.xml @@ -1,312 +1,315 @@ - + android:clipToPadding="false" + android:orientation="vertical"> - + - + - + - + - + - + - - - - - - - + - + + + + + + + - + - + - + - + - + - + - + + + + + + + + + + - - - - - - - \ No newline at end of file + app:layout_constraintStart_toStartOf="parent" /> + \ No newline at end of file