private const val INDEX_OBSCURED = 0 | |
private const val INDEX_BACK = 1 | |
private const val INDEX_MIDDLE = 2 | |
private const val INDEX_FRONT = 3 | |
private const val VISIBLE_CARDS_COUNT = 3 | |
/** | |
* Displays a "deck" of 2 or more Monzo cards. Use [setCards] to pass a list of (at least 2) [CardDesign]s. | |
* | |
* At most 3 cards will be shown in any settled state. If the selected card is not one of these 3, then you'll be able | |
* to see a 4th card temporarily while it's animating from the back of the deck to the front. | |
*/ | |
class ShufflingCardsView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) { | |
private val cardViews = mutableListOf<CardView>() | |
private var cardsAreFannedOut = false | |
init { | |
clipChildren = false | |
val inflater = LayoutInflater.from(context) | |
for (i in INDEX_OBSCURED..INDEX_FRONT) { | |
val view = inflater.inflate(R.layout.view_card, this, false) | |
cardViews.add(view as CardView) | |
addView(view) | |
} | |
resetZIndexToMatchCardOrder() | |
} | |
/** | |
* z-index matches the order of the cardViews in the list | |
*/ | |
private fun resetZIndexToMatchCardOrder() { | |
for (i in INDEX_OBSCURED..INDEX_FRONT) { | |
cardViews[i].z = i.toFloat() | |
} | |
} | |
private fun resetInternalRotationToMatchCardOrder() { | |
cardViews[INDEX_BACK].animateChildRotation(7f) | |
cardViews[INDEX_MIDDLE].animateChildRotation(-7f) | |
// OBSCURED matches FRONT to ensure that it's obscured when the color is suddenly set | |
cardViews[INDEX_OBSCURED].animateChildRotation(0f) | |
cardViews[INDEX_FRONT].animateChildRotation(0f) | |
} | |
fun setCards(cardDesigns: List<CardDesign>) { | |
if (cardDesigns.isEmpty() || cardDesigns.size == 1) { | |
throw IllegalArgumentException("Need at least 2 card(s), you provided: $cardDesigns") | |
} | |
// clear the colors from all the cardViews | |
cardViews.forEach { it.setCard(null) } | |
val visibleRectangles = cardDesigns.take(VISIBLE_CARDS_COUNT) | |
visibleRectangles.forEachIndexed { index, rectangle -> | |
// want the ones at the top of the list to be at the front of the `cardViews` "stack", | |
// where a higher index indicates an elements is in front of another | |
cardViews[VISIBLE_CARDS_COUNT - index].setCard(rectangle) | |
} | |
if (cardDesigns.size == 2) { | |
fanTwoCardsOut() | |
} else { | |
fanThreeCardsOut() | |
} | |
cardsAreFannedOut = true | |
} | |
fun moveToFront(cardDesign: CardDesign) { | |
if (cardsAreFannedOut) { | |
collateCardsAndThen { moveSelectedCardToFront(cardDesign) } | |
cardsAreFannedOut = false | |
} else { | |
moveSelectedCardToFront(cardDesign) | |
} | |
} | |
private fun moveSelectedCardToFront(cardDesign: CardDesign) { | |
if (cardViews[INDEX_FRONT].represents(cardDesign)) { | |
return | |
} | |
val cardView = cardViews.find { it.represents(cardDesign) } | |
// cardDesign isn’t a visible card - let’s set the color on the obscured card view and use that | |
?: cardViews[INDEX_OBSCURED].apply { setCard(cardDesign) } | |
// we change the pivot so that the rotations are made around the correct fulcrum | |
cardView.pivotX = cardView.width.toFloat() | |
cardView.pivotY = cardView.height.toFloat() | |
cardView.animateCardOutToPeakAndThen { | |
// update list order, then recalculate the z-index and rotations | |
cardViews.remove(cardView) | |
cardViews.add(cardView) | |
resetZIndexToMatchCardOrder() | |
resetInternalRotationToMatchCardOrder() | |
cardView.animateCardFromPeakOutToIn() | |
} | |
} | |
private fun View.animateCardOutToPeakAndThen(doOnEnd: () -> Unit) { | |
animate() | |
.rotation(45f) | |
.translationXBy(45.dp.toFloat()) | |
.translationYBy(-120.dp.toFloat()) | |
.setDuration(200) | |
.setInterpolator(FastOutSlowInInterpolator()) | |
.withEndAction { doOnEnd() } | |
.start() | |
} | |
private fun View.animateCardFromPeakOutToIn() { | |
animate() | |
.rotation(0f) | |
.translationX(0f) | |
.translationY(0f) | |
.setDuration(300) | |
.setInterpolator(FastOutSlowInInterpolator()) | |
.start() | |
} | |
private fun fanTwoCardsOut() { | |
cardViews[INDEX_MIDDLE].animate() | |
.rotation(-17f) | |
.translationYBy(-20.dp.toFloat()) | |
.startSpread() | |
cardViews[INDEX_FRONT].animate() | |
.rotation(17f) | |
.translationYBy(20.dp.toFloat()) | |
.startSpread() | |
} | |
private fun fanThreeCardsOut() { | |
cardViews[INDEX_BACK].animate() | |
.rotation(-17f) | |
.translationXBy(-12.dp.toFloat()) | |
.translationYBy(-60.dp.toFloat()) | |
.startSpread() | |
cardViews[INDEX_MIDDLE].animate() | |
.translationXBy(4.dp.toFloat()) | |
.startSpread() | |
cardViews[INDEX_FRONT].animate() | |
.rotation(17f) | |
.translationYBy(60.dp.toFloat()) | |
.startSpread() | |
} | |
private fun collateCardsAndThen(doOnEnd: () -> Unit) { | |
resetInternalRotationToMatchCardOrder() | |
cardViews[INDEX_BACK].animate().withEndAction { doOnEnd() }.startUnspread() | |
cardViews[INDEX_MIDDLE].animate().startUnspread() | |
cardViews[INDEX_FRONT].animate().startUnspread() | |
} | |
private fun ViewPropertyAnimator.startSpread() { | |
setDuration(600) | |
.setStartDelay(1000) | |
.setInterpolator(OvershootInterpolator(3f)) | |
.start() | |
} | |
private fun ViewPropertyAnimator.startUnspread() { | |
setDuration(600) | |
.setStartDelay(0) | |
.setInterpolator(FastOutSlowInInterpolator()) | |
.rotation(0f) | |
.translationX(0f) | |
.translationY(0f) | |
.start() | |
} | |
} | |
internal class CardView(context: Context, attrs: AttributeSet) : FrameLayout(context, attrs) { | |
private var cardDesign: CardDesign? = null | |
private val imageView: ImageView = ImageView(context).apply { | |
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) | |
addView(this) | |
} | |
fun represents(cardDesign: CardDesign) = cardDesign == this.cardDesign | |
fun setCard(cardDesign: CardDesign?) { | |
this.cardDesign = cardDesign | |
if (cardDesign != null) { | |
val selectedCardRes = when (cardDesign) { | |
CardDesign.PLUS_HOT_CORAL -> R.drawable.img_card_hot_coral | |
CardDesign.PLUS_LAGOON_BLUE -> R.drawable.img_card_lagoon_blue | |
CardDesign.PLUS_MIDNIGHT_SKY -> R.drawable.img_card_midnight_sky | |
// ... | |
} | |
imageView.setImageResource(selectedCardRes) | |
} else { | |
imageView.setImageDrawable(null) | |
} | |
} | |
fun animateChildRotation(rotation: Float) { | |
imageView.animate() | |
.setDuration(300) | |
.setInterpolator(FastOutSlowInInterpolator()) | |
.rotation(rotation) | |
.start() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment