Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 Q (API 29) 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 isImeVisible: 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
isImeVisible = 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, isImeVisible)
}
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)
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
<androidx.core.widget.NestedScrollView
android:layout_width="0dp"
android:layout_height="0dp"
android:fillViewport="true"
android:scrollbars="none">
<LinearLayout
android:scrollbars="none"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
Expand Down Expand Up @@ -77,5 +82,5 @@
android:overScrollMode="never"
tools:listitem="@layout/gps_item_faq_label" />
</LinearLayout>
</ScrollView>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>