Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

/**
Expand All @@ -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].
Expand All @@ -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()
Expand Down Expand Up @@ -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<EditText>(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) {
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -477,10 +529,22 @@ class ReviewFragment private constructor(
override fun onSaveInstanceState(outState: Bundle) {
val height = view?.findViewById<ViewPager2>(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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {}
}
Expand Down Expand Up @@ -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<TextView>(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)) {
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -85,3 +90,15 @@ internal suspend fun <T> Flow<T>.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)
}
}