Skip to content
Draft
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,27 @@ You can implement reloading by calling `RecyclerView.Adapter.notifyDataSetChange
CardStackLayoutManager.setStackFrom(StackFrom.None)
```

## Carousel Style

Switch the deck into a carousel-inspired presentation to mimic the effect requested in [#310](https://github.com/yuyakaido/CardStackView/issues/310).
The `translationInterval` still controls spacing, while `CarouselSetting` lets you fine tune the curve.

```kotlin
val carousel = CarouselSetting(
orientation = CarouselOrientation.Horizontal,
scaleMultiplier = 0.18f,
minScale = 0.65f,
tiltAngle = 8f
)

manager.setStackStyle(CardStackStyle.Carousel)
manager.setCarouselSetting(carousel)
manager.setStackFrom(StackFrom.Bottom)
manager.setTranslationInterval(16f)
```

You can toggle this mode in the sample app via the drawer menu to compare the classic stack with the carousel effect.

## Visible Count

| Default | Value | Sample |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import android.content.Context
import android.graphics.PointF
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.view.animation.Interpolator
Expand All @@ -13,6 +12,9 @@ import androidx.annotation.IntRange
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Recycler
import androidx.recyclerview.widget.RecyclerView.SmoothScroller.ScrollVectorProvider
import com.yuyakaido.android.cardstackview.CardStackStyle
import com.yuyakaido.android.cardstackview.CarouselOrientation
import com.yuyakaido.android.cardstackview.CarouselSetting
import com.yuyakaido.android.cardstackview.internal.CardStackSetting
import com.yuyakaido.android.cardstackview.internal.CardStackSmoothScroller
import com.yuyakaido.android.cardstackview.internal.CardStackState
Expand Down Expand Up @@ -302,9 +304,12 @@ class CardStackLayoutManager
updateOverlay(child)
} else {
val currentIndex = i - cardStackState.topPosition
updateTranslation(child, currentIndex)
updateScale(child, currentIndex)
resetRotation(child)
if (cardStackSetting.stackStyle == CardStackStyle.Carousel) {
updateCarousel(child, currentIndex)
} else {
updateTranslation(child, currentIndex)
updateScale(child, currentIndex)
}
resetOverlay(child)
}
i++
Expand Down Expand Up @@ -391,6 +396,53 @@ class CardStackLayoutManager
view.scaleY = 1.0f
}

private fun updateCarousel(view: View, index: Int) {
val carousel = cardStackSetting.carouselSetting
val distance = (index.toFloat() - cardStackState.ratio).coerceAtLeast(0f)
val maxDepth = (cardStackSetting.visibleCount - 1).coerceAtLeast(1)
val normalizedDepth = (distance / maxDepth.toFloat()).coerceIn(0f, 1f)
val scale =
(1.0f - carousel.scaleMultiplier * distance).coerceAtLeast(carousel.minScale)
view.scaleX = scale
view.scaleY = scale

val translationIntervalPx =
DisplayUtil.dpToPx(context, cardStackSetting.translationInterval).toFloat()
val intervalShift = translationIntervalPx * distance
val sizeShift = if (carousel.orientation == CarouselOrientation.Vertical) {
view.measuredHeight * (1.0f - scale) / 2.0f
} else {
view.measuredWidth * (1.0f - scale) / 2.0f
}
val direction = resolveCarouselDirectionSign(carousel.orientation)
val translation = direction * (intervalShift + sizeShift)

if (carousel.orientation == CarouselOrientation.Vertical) {
view.translationY = translation
view.translationX = 0.0f
} else {
view.translationX = translation
view.translationY = 0.0f
}

val rotation = direction * carousel.tiltAngle * normalizedDepth
view.rotation = rotation
}

private fun resolveCarouselDirectionSign(orientation: CarouselOrientation): Float {
return when (orientation) {
CarouselOrientation.Vertical -> when (cardStackSetting.stackFrom) {
StackFrom.Top, StackFrom.TopAndLeft, StackFrom.TopAndRight -> -1f
else -> 1f
}

CarouselOrientation.Horizontal -> when (cardStackSetting.stackFrom) {
StackFrom.Left, StackFrom.TopAndLeft, StackFrom.BottomAndLeft -> -1f
else -> 1f
}
}
}

private fun updateRotation(view: View) {
val degree =
cardStackState.dx * cardStackSetting.maxDegree / width * cardStackState.proportion
Expand All @@ -399,6 +451,8 @@ class CardStackLayoutManager

private fun resetRotation(view: View) {
view.rotation = 0.0f
view.rotationX = 0.0f
view.rotationY = 0.0f
}

private fun updateOverlay(view: View) {
Expand Down Expand Up @@ -483,6 +537,14 @@ class CardStackLayoutManager
cardStackSetting.stackFrom = stackFrom
}

fun setStackStyle(stackStyle: CardStackStyle) {
cardStackSetting.stackStyle = stackStyle
}

fun setCarouselSetting(carouselSetting: CarouselSetting) {
cardStackSetting.carouselSetting = carouselSetting
}

fun setVisibleCount(@IntRange(from = 1) visibleCount: Int) {
require(visibleCount >= 1) { "VisibleCount must be greater than 0." }
cardStackSetting.visibleCount = visibleCount
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.yuyakaido.android.cardstackview

enum class CardStackStyle {
Stack,
Carousel
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.yuyakaido.android.cardstackview

import androidx.annotation.FloatRange

enum class CarouselOrientation {
Vertical,
Horizontal
}

data class CarouselSetting @JvmOverloads constructor(
val orientation: CarouselOrientation = CarouselOrientation.Vertical,
@FloatRange(from = 0.0) val scaleMultiplier: Float = 0.15f,
@FloatRange(from = 0.0, to = 1.0) val minScale: Float = 0.6f,
@FloatRange(from = 0.0, to = 90.0) val tiltAngle: Float = 8f
) {
init {
require(scaleMultiplier >= 0f) { "Carousel scaleMultiplier must be greater than or equal to 0." }
require(minScale in 0f..1f) { "Carousel minScale must be between 0.0 and 1.0." }
require(tiltAngle in 0f..90f) { "Carousel tiltAngle must be within 0 - 90 degrees." }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.yuyakaido.android.cardstackview.internal

import android.view.animation.Interpolator
import android.view.animation.LinearInterpolator
import com.yuyakaido.android.cardstackview.CardStackStyle
import com.yuyakaido.android.cardstackview.CarouselSetting
import com.yuyakaido.android.cardstackview.Direction
import com.yuyakaido.android.cardstackview.RewindAnimationSetting
import com.yuyakaido.android.cardstackview.StackFrom
Expand All @@ -10,6 +12,8 @@ import com.yuyakaido.android.cardstackview.SwipeableMethod

class CardStackSetting {
var stackFrom: StackFrom = StackFrom.None
var stackStyle: CardStackStyle = CardStackStyle.Stack
var carouselSetting: CarouselSetting = CarouselSetting()
var visibleCount: Int = 3
var translationInterval: Float = 8.0f
var scaleInterval: Float = 0.95f // 0.0f - 1.0f
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.yuyakaido.android.cardstackview.CardStackStyle
import com.yuyakaido.android.cardstackview.CarouselOrientation
import com.yuyakaido.android.cardstackview.CarouselSetting
import com.yuyakaido.android.cardstackview.internal.CardStackState
import org.junit.Before
import org.junit.Test
Expand Down Expand Up @@ -122,6 +125,19 @@ class CardStackLayoutManagerTest {
assertEquals(StackFrom.Top, layoutManager.cardStackSetting.stackFrom)
}

@Test
fun `setStackStyle should update setting`() {
layoutManager.setStackStyle(CardStackStyle.Carousel)
assertEquals(CardStackStyle.Carousel, layoutManager.cardStackSetting.stackStyle)
}

@Test
fun `setCarouselSetting should update setting`() {
val setting = CarouselSetting(CarouselOrientation.Horizontal, 0.2f, 0.7f, 6f)
layoutManager.setCarouselSetting(setting)
assertEquals(setting, layoutManager.cardStackSetting.carouselSetting)
}

@Test
fun `setVisibleCount should update setting`() {
layoutManager.setVisibleCount(5)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.yuyakaido.android.cardstackview

import org.junit.Assert.assertEquals
import org.junit.Test

class CarouselSettingTest {

@Test
fun `default values should be vertical`() {
val setting = CarouselSetting()
assertEquals(CarouselOrientation.Vertical, setting.orientation)
assertEquals(0.15f, setting.scaleMultiplier)
assertEquals(0.6f, setting.minScale)
assertEquals(8f, setting.tiltAngle)
}

@Test(expected = IllegalArgumentException::class)
fun `negative scale multiplier should throw`() {
CarouselSetting(scaleMultiplier = -0.1f)
}

@Test(expected = IllegalArgumentException::class)
fun `minScale outside range should throw`() {
CarouselSetting(minScale = 1.5f)
}

@Test(expected = IllegalArgumentException::class)
fun `tilt angle outside range should throw`() {
CarouselSetting(tiltAngle = 120f)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.yuyakaido.android.cardstackview.internal

import android.view.animation.LinearInterpolator
import com.yuyakaido.android.cardstackview.CardStackStyle
import com.yuyakaido.android.cardstackview.CarouselOrientation
import com.yuyakaido.android.cardstackview.CarouselSetting
import com.yuyakaido.android.cardstackview.Direction
import com.yuyakaido.android.cardstackview.StackFrom
import com.yuyakaido.android.cardstackview.SwipeableMethod
Expand All @@ -20,6 +23,8 @@ class CardStackSettingTest {
@Test
fun `default values should be correct`() {
assertEquals(StackFrom.None, cardStackSetting.stackFrom)
assertEquals(CardStackStyle.Stack, cardStackSetting.stackStyle)
assertEquals(CarouselSetting(), cardStackSetting.carouselSetting)
assertEquals(3, cardStackSetting.visibleCount)
assertEquals(8.0f, cardStackSetting.translationInterval, 0.01f)
assertEquals(0.95f, cardStackSetting.scaleInterval, 0.01f)
Expand All @@ -40,6 +45,19 @@ class CardStackSettingTest {
assertEquals(StackFrom.Top, cardStackSetting.stackFrom)
}

@Test
fun `stackStyle should be settable`() {
cardStackSetting.stackStyle = CardStackStyle.Carousel
assertEquals(CardStackStyle.Carousel, cardStackSetting.stackStyle)
}

@Test
fun `carouselSetting should be settable`() {
val setting = CarouselSetting(CarouselOrientation.Horizontal, 0.2f, 0.7f, 6f)
cardStackSetting.carouselSetting = setting
assertEquals(setting, cardStackSetting.carouselSetting)
}

@Test
fun `visibleCount should be settable`() {
cardStackSetting.visibleCount = 5
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.yuyakaido.android.cardstackview.sample

import android.os.Bundle
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
Expand All @@ -25,9 +26,11 @@ class MainActivity : AppCompatActivity(), CardStackListener {
private val cardStackView by lazy { findViewById<CardStackView>(R.id.card_stack_view) }
private val manager by lazy { CardStackLayoutManager(this, this) }
private val adapter by lazy { CardStackAdapter(createSpots()) }
private var isCarouselStyleEnabled = false

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
isCarouselStyleEnabled = savedInstanceState?.getBoolean(STATE_CAROUSEL_STYLE) ?: false
setContentView(R.layout.activity_main)
setupNavigation()
setupCardStackView()
Expand Down Expand Up @@ -94,10 +97,16 @@ class MainActivity : AppCompatActivity(), CardStackListener {
R.id.remove_spot_from_last -> removeLast(1)
R.id.replace_first_spot -> replace()
R.id.swap_first_for_last -> swap()
R.id.toggle_carousel -> {
isCarouselStyleEnabled = !isCarouselStyleEnabled
applyStackStyle()
updateCarouselMenuTitle(menuItem)
}
}
drawerLayout.closeDrawers()
true
}
updateCarouselMenuTitle(navigationView.menu.findItem(R.id.toggle_carousel))
}

private fun setupCardStackView() {
Expand Down Expand Up @@ -140,9 +149,7 @@ class MainActivity : AppCompatActivity(), CardStackListener {
}

private fun initialize() {
manager.setStackFrom(StackFrom.None)
manager.setVisibleCount(3)
manager.setTranslationInterval(8.0f)
manager.setScaleInterval(0.95f)
manager.setSwipeThreshold(0.3f)
manager.setMaxDegree(20.0f)
Expand All @@ -153,13 +160,42 @@ class MainActivity : AppCompatActivity(), CardStackListener {
manager.setOverlayInterpolator(LinearInterpolator())
cardStackView.layoutManager = manager
cardStackView.adapter = adapter
applyStackStyle()
cardStackView.itemAnimator.apply {
if (this is DefaultItemAnimator) {
supportsChangeAnimations = false
}
}
}

private fun applyStackStyle() {
if (isCarouselStyleEnabled) {
manager.setStackStyle(CardStackStyle.Carousel)
manager.setCarouselSetting(
CarouselSetting(
orientation = CarouselOrientation.Vertical,
scaleMultiplier = 0.18f,
minScale = 0.65f,
tiltAngle = 10f
)
)
manager.setStackFrom(StackFrom.Bottom)
manager.setTranslationInterval(16.0f)
} else {
manager.setStackStyle(CardStackStyle.Stack)
manager.setCarouselSetting(CarouselSetting())
manager.setStackFrom(StackFrom.None)
manager.setTranslationInterval(8.0f)
}
manager.requestLayout()
}

private fun updateCarouselMenuTitle(menuItem: MenuItem?) {
menuItem?.title = getString(
if (isCarouselStyleEnabled) R.string.disable_carousel else R.string.enable_carousel
)
}

private fun paginate() {
val old = adapter.getSpots()
val new = old.plus(createSpots())
Expand Down Expand Up @@ -275,4 +311,13 @@ class MainActivity : AppCompatActivity(), CardStackListener {
Spot(name = "Great Wall of China", city = "China", url = "https://images.unsplash.com/photo-1558981017-9c65fb6f2530")
)

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(STATE_CAROUSEL_STYLE, isCarouselStyleEnabled)
}

companion object {
private const val STATE_CAROUSEL_STYLE = "state_carousel_style"
}

}
Loading