diff --git a/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/review/ReviewFragment.kt b/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/review/ReviewFragment.kt index db3246809..4848ad6f1 100644 --- a/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/review/ReviewFragment.kt +++ b/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/review/ReviewFragment.kt @@ -11,6 +11,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams +import android.view.ViewTreeObserver +import android.widget.EditText import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.core.view.ViewCompat @@ -32,6 +34,7 @@ import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayoutMediator import dev.chrisbanes.insetter.applyInsetter import dev.chrisbanes.insetter.windowInsetTypesOf +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.gini.android.health.sdk.GiniHealth import net.gini.android.health.sdk.R as HealthR @@ -55,6 +58,7 @@ import net.gini.android.internal.payment.utils.extensions.isLandscapeOrientation import net.gini.android.internal.payment.utils.extensions.isViewModelInitialized import net.gini.android.internal.payment.utils.extensions.onKeyboardAction import net.gini.android.internal.payment.utils.extensions.wrappedWithGiniPaymentThemeAndLocale +import net.gini.android.internal.payment.utils.showKeyboard import org.jetbrains.annotations.VisibleForTesting /** @@ -77,7 +81,18 @@ internal interface ReviewFragmentListener { fun onToTheBankButtonClicked(paymentProviderName: String, paymentDetails: PaymentDetails) } - +/** + * Delay duration (in milliseconds) used to allow the view to settle down before requesting focus. + * + * A value of 200ms was chosen based on observed behaviour on Android 10 devices and below, where + * immediately requesting keyboard focus after view creation can result in the keyboard not + * appearing. + * This delay helps ensure that the keyboard is reliably shown when the field requests focus. + */ +private const val VIEW_SETTLE_DELAY_MS = 200L +private const val KEY_IME_WAS_VISIBLE = "ime_was_visible" +private const val KEY_FOCUSED_ID = "focused_view_id" +private const val KEYBOARD_VISIBILITY_RATIO = 0.25f /** * The [ReviewFragment] displays an invoice’s pages and payment information extractions. It also lets users pay the * invoice with the bank they selected in the [BankSelectionBottomSheet]. @@ -93,6 +108,8 @@ class ReviewFragment private constructor( private val viewModel: ReviewViewModel by viewModels{ viewModelFactory ?: object : ViewModelProvider.Factory {} } + private var imeVisibleNow: Boolean = false + private var preRKeyboardTracker: ViewTreeObserver.OnGlobalLayoutListener? = null private var binding: GhsFragmentReviewBinding by autoCleared() private var documentPageAdapter: DocumentPageAdapter by autoCleared() @@ -169,8 +186,31 @@ class ReviewFragment private constructor( if (resources.isLandscapeOrientation()) { setupLandscapeBehavior() } + + // handling keyboard in Version <= Q (Pie and below) after orientation change + if (preQ()) { + startPreRKeyboardTracker(view) + restoreImeIfNeeded(view, savedInstanceState) + } } + private fun restoreImeIfNeeded(root: View, savedInstanceState: Bundle?) { + val focusedId = savedInstanceState?.getInt(KEY_FOCUSED_ID) ?: View.NO_ID + val imeWasVisible = savedInstanceState?.getBoolean(KEY_IME_WAS_VISIBLE) ?: false + if (focusedId == View.NO_ID || !imeWasVisible) return + + root.post { + val et = root.findViewById(focusedId) + if (et?.isShown == true && et.isEnabled && et.isFocusable) { + viewLifecycleOwner.lifecycleScope.launch { + delay(VIEW_SETTLE_DELAY_MS) + et.showKeyboard() // Helper already requests focus + } + } + } + } + + private fun GhsFragmentReviewBinding.setStateListeners() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { @@ -376,6 +416,18 @@ class ReviewFragment private constructor( }) } + private fun startPreRKeyboardTracker(root: View) { + val listener = ViewTreeObserver.OnGlobalLayoutListener { + val r = android.graphics.Rect() + root.getWindowVisibleDisplayFrame(r) + val visible = r.height() + val heightDiff = root.rootView.height - visible + imeVisibleNow = heightDiff > root.rootView.height * KEYBOARD_VISIBILITY_RATIO // keyboard threshold + } + root.viewTreeObserver.addOnGlobalLayoutListener(listener) + preRKeyboardTracker = listener + } + private fun GhsFragmentReviewBinding.showInfoBar() { root.doOnLayout { if (resources.isLandscapeOrientation()) { @@ -477,10 +529,22 @@ class ReviewFragment private constructor( override fun onSaveInstanceState(outState: Bundle) { val height = view?.findViewById(HealthR.id.pager)?.layoutParams?.height ?: -1 outState.putInt(PAGER_HEIGHT, height) + if (preQ()) { + val focusedId = view?.findFocus()?.id ?: View.NO_ID + outState.putInt(KEY_FOCUSED_ID, focusedId) + outState.putBoolean(KEY_IME_WAS_VISIBLE, imeVisibleNow) + } super.onSaveInstanceState(outState) } + private fun preQ() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q - + override fun onDestroyView() { + preRKeyboardTracker?.let { + view?.viewTreeObserver?.removeOnGlobalLayoutListener(it) + } + preRKeyboardTracker = null + super.onDestroyView() + } internal companion object { private const val PAGER_HEIGHT = "pager_height" diff --git a/internal-payment-sdk/sdk/src/main/java/net/gini/android/internal/payment/review/reviewBottomSheet/ReviewBottomSheet.kt b/internal-payment-sdk/sdk/src/main/java/net/gini/android/internal/payment/review/reviewBottomSheet/ReviewBottomSheet.kt index 743871958..25622d863 100644 --- a/internal-payment-sdk/sdk/src/main/java/net/gini/android/internal/payment/review/reviewBottomSheet/ReviewBottomSheet.kt +++ b/internal-payment-sdk/sdk/src/main/java/net/gini/android/internal/payment/review/reviewBottomSheet/ReviewBottomSheet.kt @@ -6,11 +6,16 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewTreeObserver import android.widget.FrameLayout +import android.widget.TextView import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import net.gini.android.internal.payment.GiniInternalPaymentModule import net.gini.android.internal.payment.api.model.PaymentDetails import net.gini.android.internal.payment.databinding.GpsBottomSheetReviewBinding @@ -24,13 +29,25 @@ import net.gini.android.internal.payment.utils.extensions.isLandscapeOrientation import net.gini.android.internal.payment.utils.extensions.isViewModelInitialized import net.gini.android.internal.payment.utils.extensions.onKeyboardAction import net.gini.android.internal.payment.utils.extensions.setBackListener +import net.gini.android.internal.payment.utils.showKeyboard + +private const val VIEW_SETTLE_DELAY_MS = 200L +private const val KEY_IME_WAS_VISIBLE = "ime_was_visible" +private const val KEY_FOCUSED_ID = "focused_view_id" +private const val KEYBOARD_VISIBILITY_RATIO = 0.25f class ReviewBottomSheet private constructor( private val viewModelFactory: ViewModelProvider.Factory? ) : GpsBottomSheetDialogFragment() { + private var lastFocusedId: Int = View.NO_ID + private var focusTracker: ViewTreeObserver.OnGlobalFocusChangeListener? = null + constructor() : this(null) + private var imeVisibleNow: Boolean = false + private var preRKeyboardTracker: ViewTreeObserver.OnGlobalLayoutListener? = null + private val viewModel: ReviewBottomSheetViewModel by viewModels { viewModelFactory ?: object : ViewModelProvider.Factory {} } @@ -72,6 +89,34 @@ class ReviewBottomSheet private constructor( return dialog } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + focusTracker = ViewTreeObserver.OnGlobalFocusChangeListener { _, new -> + val id = new?.id ?: View.NO_ID + if (id != View.NO_ID) lastFocusedId = id + } + view.viewTreeObserver.addOnGlobalFocusChangeListener(focusTracker) + + startPreRKeyboardTracker(view) + restoreFocusAndImeIfNeeded(view, savedInstanceState) + } + + private fun restoreFocusAndImeIfNeeded(root: View, savedInstanceState: Bundle?) { + val focusedId = savedInstanceState?.getInt(KEY_FOCUSED_ID) ?: View.NO_ID + val imeWasVisible = savedInstanceState?.getBoolean(KEY_IME_WAS_VISIBLE) ?: false + if (focusedId == View.NO_ID || !imeWasVisible) return + root.post { + val et = root.findViewById(focusedId) + if (et?.isShown == true && et.isEnabled && et.isFocusable) { + viewLifecycleOwner.lifecycleScope.launch { + delay(VIEW_SETTLE_DELAY_MS) + et.showKeyboard() + } + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (viewModelFactory == null && !isViewModelInitialized(ReviewBottomSheetViewModel::class)) { @@ -101,11 +146,42 @@ class ReviewBottomSheet private constructor( return binding.root } + private fun startPreRKeyboardTracker(root: View) { + val listener = ViewTreeObserver.OnGlobalLayoutListener { + val r = android.graphics.Rect() + root.getWindowVisibleDisplayFrame(r) + val visible = r.height() + val heightDiff = root.rootView.height - visible + imeVisibleNow = + heightDiff > root.rootView.height * KEYBOARD_VISIBILITY_RATIO // ~keyboard threshold + } + root.viewTreeObserver.addOnGlobalLayoutListener(listener) + preRKeyboardTracker = listener + } + + override fun onDestroyView() { + preRKeyboardTracker?.let { + view?.viewTreeObserver?.removeOnGlobalLayoutListener(it) + } + focusTracker?.let { + view?.viewTreeObserver?.removeOnGlobalFocusChangeListener(it) + } + preRKeyboardTracker = null + focusTracker = null + super.onDestroyView() + } + override fun onCancel(dialog: DialogInterface) { viewModel.backListener?.backCalled() super.onCancel(dialog) } + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt(KEY_FOCUSED_ID, lastFocusedId) + outState.putBoolean(KEY_IME_WAS_VISIBLE, imeVisibleNow) + super.onSaveInstanceState(outState) + } + companion object { fun newInstance( configuration: ReviewConfiguration = ReviewConfiguration(), diff --git a/internal-payment-sdk/sdk/src/main/java/net/gini/android/internal/payment/utils/Extensions.kt b/internal-payment-sdk/sdk/src/main/java/net/gini/android/internal/payment/utils/Extensions.kt index a118fce19..56b3edc2b 100644 --- a/internal-payment-sdk/sdk/src/main/java/net/gini/android/internal/payment/utils/Extensions.kt +++ b/internal-payment-sdk/sdk/src/main/java/net/gini/android/internal/payment/utils/Extensions.kt @@ -1,9 +1,14 @@ package net.gini.android.internal.payment.utils import android.R +import android.content.Context import android.content.res.ColorStateList +import android.os.Build import android.text.Editable import android.text.TextWatcher +import android.view.View +import android.view.WindowInsets +import android.view.inputmethod.InputMethodManager import android.widget.Button import androidx.annotation.ColorInt import androidx.annotation.IntRange @@ -85,3 +90,15 @@ internal suspend fun Flow.withPrev() = flow { prev = it } } + + +fun View.showKeyboard() { + requestFocus() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + windowInsetsController?.show(WindowInsets.Type.ime()) + } else { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + } +} +