manage card mode improve part 2 - animations
Auto Tag on Version Change / check-version (push) Successful in 5s
Auto Tag on Version Change / check-version (push) Successful in 5s
This commit is contained in:
@@ -14,8 +14,11 @@ import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.doOnNextLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -41,6 +44,15 @@ class CardsFragment : Fragment() {
|
||||
private var pendingQrAccountNumber: String? = null
|
||||
private var isManageMode: Boolean = false
|
||||
|
||||
// Carousel snapshot captured on enter, used to reverse the exit animation
|
||||
private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout
|
||||
private var carouselCardCenterX = 0f // card center X relative to contentLayout
|
||||
private var carouselTextLayoutTop = 0f // tvSelectedCardType layout top relative to contentLayout
|
||||
|
||||
// Swipe-to-dismiss tracking
|
||||
private var swipeDragStartRawY = 0f
|
||||
private var swipeIsDragging = false
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
@@ -133,6 +145,50 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
setManageMode(!isManageMode)
|
||||
}
|
||||
|
||||
// Swipe-down on the manage card to dismiss manage mode
|
||||
binding.manageCardView.root.setOnTouchListener { _, event ->
|
||||
if (!isManageMode) return@setOnTouchListener false
|
||||
val mgr = binding.manageCardView.root
|
||||
when (event.action) {
|
||||
android.view.MotionEvent.ACTION_DOWN -> {
|
||||
mgr.animate().cancel()
|
||||
binding.tvSelectedCardType.animate().cancel()
|
||||
swipeDragStartRawY = event.rawY
|
||||
swipeIsDragging = false
|
||||
true
|
||||
}
|
||||
android.view.MotionEvent.ACTION_MOVE -> {
|
||||
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
|
||||
if (dy > 12f || swipeIsDragging) {
|
||||
swipeIsDragging = true
|
||||
mgr.translationY = dy
|
||||
binding.tvSelectedCardType.translationY = dy * 0.6f
|
||||
val scale = 1f - (dy / (binding.contentLayout.height * 2.5f)).coerceIn(0f, 0.12f)
|
||||
mgr.scaleX = scale
|
||||
mgr.scaleY = scale
|
||||
true
|
||||
} else false
|
||||
}
|
||||
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
|
||||
if (swipeIsDragging) {
|
||||
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
|
||||
swipeIsDragging = false
|
||||
if (dy > 130f) {
|
||||
setManageMode(false)
|
||||
} else {
|
||||
// Snap back
|
||||
mgr.animate().translationY(0f).scaleX(1f).scaleY(1f)
|
||||
.setDuration(280).setInterpolator(DecelerateInterpolator()).start()
|
||||
binding.tvSelectedCardType.animate().translationY(0f)
|
||||
.setDuration(280).setInterpolator(DecelerateInterpolator()).start()
|
||||
}
|
||||
true
|
||||
} else false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnScanToPay.setOnClickListener {
|
||||
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
|
||||
if (item is CardItem.Mib) {
|
||||
@@ -162,39 +218,148 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
private fun setManageMode(enabled: Boolean) {
|
||||
isManageMode = enabled
|
||||
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
|
||||
val gone = View.GONE
|
||||
val visible = View.VISIBLE
|
||||
binding.btnManageCard.visibility = if (enabled) gone else visible
|
||||
binding.topSpacer.visibility = if (enabled) gone else visible
|
||||
binding.rvCards.visibility = if (enabled) gone else visible
|
||||
binding.pageIndicator.visibility = if (enabled) gone else visible
|
||||
binding.llPayButtons.visibility = if (enabled) gone else visible
|
||||
binding.llManageButtons.visibility = if (enabled) visible else gone
|
||||
binding.manageCardView.root.visibility = if (enabled) visible else gone
|
||||
if (!enabled) buildDots(cards.size, currentCardPosition)
|
||||
if (enabled) {
|
||||
val item = cards.getOrNull(currentCardPosition) ?: return
|
||||
val cv = binding.manageCardView
|
||||
when (item) {
|
||||
is CardItem.Mib -> {
|
||||
cv.tvCardOwner.text = item.card.cardHolderName
|
||||
cv.tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
|
||||
val assetPath = cardImageAsset(item.card)
|
||||
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
|
||||
else cv.ivCardImage.setImageDrawable(null)
|
||||
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
|
||||
cv.root.alpha = 1f
|
||||
}
|
||||
is CardItem.Bml -> {
|
||||
cv.tvCardOwner.text = item.account.accountBriefName
|
||||
cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
|
||||
loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||
bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
|
||||
cv.root.alpha = if (isActive) 1f else 0.45f
|
||||
}
|
||||
if (enabled) enterManageMode() else exitManageMode()
|
||||
}
|
||||
|
||||
private fun enterManageMode() {
|
||||
val item = cards.getOrNull(currentCardPosition) ?: return
|
||||
|
||||
// Bind card data
|
||||
val cv = binding.manageCardView
|
||||
when (item) {
|
||||
is CardItem.Mib -> {
|
||||
cv.tvCardOwner.text = item.card.cardHolderName
|
||||
cv.tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
|
||||
val assetPath = cardImageAsset(item.card)
|
||||
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
|
||||
else cv.ivCardImage.setImageDrawable(null)
|
||||
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
|
||||
cv.root.alpha = 1f
|
||||
}
|
||||
is CardItem.Bml -> {
|
||||
cv.tvCardOwner.text = item.account.accountBriefName
|
||||
cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
|
||||
loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||
bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
|
||||
cv.root.alpha = if (isActive) 1f else 0.45f
|
||||
}
|
||||
}
|
||||
|
||||
// Capture positions BEFORE layout changes (for enter animation + exit animation later)
|
||||
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||
val lm = binding.rvCards.layoutManager as? LinearLayoutManager
|
||||
val srcView = lm?.findViewByPosition(currentCardPosition)
|
||||
val srcLoc = IntArray(2).also { srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] } }
|
||||
val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat()
|
||||
val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f
|
||||
|
||||
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||
val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat()
|
||||
|
||||
// Apply layout changes
|
||||
binding.btnManageCard.visibility = View.GONE
|
||||
binding.topSpacer.visibility = View.GONE
|
||||
binding.rvCards.visibility = View.GONE
|
||||
binding.pageIndicator.visibility = View.GONE
|
||||
binding.llPayButtons.visibility = View.GONE
|
||||
binding.llManageButtons.visibility = View.VISIBLE
|
||||
binding.manageCardView.root.visibility = View.VISIBLE
|
||||
|
||||
// After layout pass, compute offsets, save carousel snapshot, and animate
|
||||
binding.contentLayout.doOnNextLayout {
|
||||
val mgr = binding.manageCardView.root
|
||||
val dstLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
|
||||
val dstTop = (dstLoc[1] - contentLoc[1]).toFloat()
|
||||
val dstCenterX = (dstLoc[0] - contentLoc[0]).toFloat() + mgr.width / 2f
|
||||
|
||||
val scaleStart = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
|
||||
val transXStart = srcCenterX - dstCenterX
|
||||
val transYStart = srcScreenTop - dstTop
|
||||
|
||||
// Save the carousel card's position (relative to contentLayout) for the exit animation
|
||||
carouselCardLayoutTop = srcScreenTop
|
||||
carouselCardCenterX = srcCenterX
|
||||
carouselTextLayoutTop = textSrcScreenTop
|
||||
|
||||
val textDstLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||
val textDstTop = (textDstLoc[1] - contentLoc[1]).toFloat()
|
||||
|
||||
mgr.pivotX = mgr.width / 2f
|
||||
mgr.pivotY = 0f
|
||||
mgr.scaleX = scaleStart
|
||||
mgr.scaleY = scaleStart
|
||||
mgr.translationX = transXStart
|
||||
mgr.translationY = transYStart
|
||||
|
||||
mgr.animate()
|
||||
.scaleX(1f).scaleY(1f)
|
||||
.translationX(0f).translationY(0f)
|
||||
.setDuration(380)
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.start()
|
||||
|
||||
binding.tvSelectedCardType.translationY = textSrcScreenTop - textDstTop
|
||||
binding.tvSelectedCardType.animate()
|
||||
.translationY(0f)
|
||||
.setDuration(380)
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun exitManageMode() {
|
||||
binding.manageCardView.root.animate().cancel()
|
||||
binding.tvSelectedCardType.animate().cancel()
|
||||
|
||||
val mgr = binding.manageCardView.root
|
||||
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||
|
||||
// Compute layout top of manage card (strip current translationY which may be from a swipe drag)
|
||||
val mgrLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
|
||||
val mgrLayoutTop = (mgrLoc[1] - contentLoc[1]).toFloat() - mgr.translationY
|
||||
|
||||
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||
val textLayoutTop = (textLoc[1] - contentLoc[1]).toFloat() - binding.tvSelectedCardType.translationY
|
||||
|
||||
// Target: animate card back to carousel position
|
||||
val scaleEnd = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
|
||||
val mgrLayoutCenterX = (mgrLoc[0] - contentLoc[0]).toFloat() - mgr.translationX + mgr.width / 2f
|
||||
val targetTransX = carouselCardCenterX - mgrLayoutCenterX
|
||||
val targetTransY = carouselCardLayoutTop - mgrLayoutTop
|
||||
|
||||
val targetTextTransY = carouselTextLayoutTop - textLayoutTop
|
||||
|
||||
mgr.pivotX = mgr.width / 2f
|
||||
mgr.pivotY = 0f
|
||||
|
||||
mgr.animate()
|
||||
.scaleX(scaleEnd).scaleY(scaleEnd)
|
||||
.translationX(targetTransX)
|
||||
.translationY(targetTransY)
|
||||
.setDuration(320)
|
||||
.setInterpolator(AccelerateInterpolator())
|
||||
.withEndAction {
|
||||
mgr.scaleX = 1f; mgr.scaleY = 1f
|
||||
mgr.translationX = 0f; mgr.translationY = 0f
|
||||
mgr.visibility = View.GONE
|
||||
binding.tvSelectedCardType.translationY = 0f
|
||||
|
||||
binding.btnManageCard.visibility = View.VISIBLE
|
||||
binding.topSpacer.visibility = View.VISIBLE
|
||||
binding.rvCards.visibility = View.VISIBLE
|
||||
binding.llPayButtons.visibility = View.VISIBLE
|
||||
binding.llManageButtons.visibility = View.GONE
|
||||
buildDots(cards.size, currentCardPosition)
|
||||
}
|
||||
.start()
|
||||
|
||||
binding.tvSelectedCardType.animate()
|
||||
.translationY(targetTextTransY)
|
||||
.setDuration(320)
|
||||
.setInterpolator(AccelerateInterpolator())
|
||||
.withEndAction { binding.tvSelectedCardType.translationY = 0f }
|
||||
.start()
|
||||
}
|
||||
|
||||
private fun applyCardScales() {
|
||||
|
||||
Reference in New Issue
Block a user