mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-04-22 07:57:59 +00:00
Initial commit: Pezkuwi Wallet Android
Security hardened release: - Code obfuscation enabled (minifyEnabled=true, shrinkResources=true) - Sensitive files excluded (google-services.json, keystores) - Branch.io key moved to BuildConfig placeholder - Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77 - Comprehensive ProGuard rules for crypto wallet - Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package io.novafoundation.nova.feature_banners_api.di
|
||||
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory
|
||||
|
||||
interface BannersFeatureApi {
|
||||
|
||||
fun sourceFactory(): BannersSourceFactory
|
||||
|
||||
fun mixinFactory(): PromotionBannersMixinFactory
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package io.novafoundation.nova.feature_banners_api.domain
|
||||
|
||||
class PromotionBanner(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val details: String,
|
||||
val backgroundUrl: String,
|
||||
val imageUrl: String,
|
||||
val clipToBounds: Boolean,
|
||||
val actionLink: String?,
|
||||
)
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
class BannerPageModel(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val image: ClipableImage,
|
||||
val background: Drawable,
|
||||
val actionUrl: String?
|
||||
)
|
||||
|
||||
class ClipableImage(val drawable: Drawable, val clip: Boolean)
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.novafoundation.nova.common.list.SingleItemAdapter
|
||||
import io.novafoundation.nova.common.utils.inflater
|
||||
import io.novafoundation.nova.common.utils.recyclerView.WithViewType
|
||||
import io.novafoundation.nova.feature_banners_api.R
|
||||
import io.novafoundation.nova.feature_banners_api.databinding.ItemPromotionBannerBinding
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.BannerPagerView
|
||||
|
||||
class PromotionBannerAdapter(
|
||||
private val closable: Boolean
|
||||
) : SingleItemAdapter<BannerHolder>(isShownByDefault = false) {
|
||||
|
||||
private var banners: List<BannerPageModel> = listOf()
|
||||
private var bannerCallback: BannerPagerView.Callback? = null
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BannerHolder {
|
||||
return BannerHolder(ItemPromotionBannerBinding.inflate(parent.inflater(), parent, false), closable)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BannerHolder, position: Int) {
|
||||
holder.bind(banners, bannerCallback)
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return BannerHolder.viewType
|
||||
}
|
||||
|
||||
fun setBanners(banners: List<BannerPageModel>) {
|
||||
this.banners = banners
|
||||
notifyChangedIfShown()
|
||||
}
|
||||
|
||||
fun setCallback(bannerCallback: BannerPagerView.Callback?) {
|
||||
this.bannerCallback = bannerCallback
|
||||
notifyChangedIfShown()
|
||||
}
|
||||
}
|
||||
|
||||
class BannerHolder(private val binder: ItemPromotionBannerBinding, closable: Boolean) : RecyclerView.ViewHolder(binder.root) {
|
||||
|
||||
companion object : WithViewType {
|
||||
override val viewType: Int = R.layout.item_promotion_banner
|
||||
}
|
||||
|
||||
init {
|
||||
binder.bannerPager.setClosable(closable)
|
||||
}
|
||||
|
||||
fun bind(banners: List<BannerPageModel>, bannerCallback: BannerPagerView.Callback?) = with(binder) {
|
||||
bannerPager.setCallback(bannerCallback)
|
||||
showBanners(banners)
|
||||
}
|
||||
|
||||
fun showBanners(banners: List<BannerPageModel>) = with(binder) {
|
||||
bannerPager.setBanners(banners)
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation
|
||||
|
||||
import io.novafoundation.nova.common.domain.ExtendedLoadingState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface PromotionBannersMixin {
|
||||
|
||||
val bannersFlow: Flow<ExtendedLoadingState<List<BannerPageModel>>>
|
||||
|
||||
fun closeBanner(banner: BannerPageModel)
|
||||
|
||||
fun startBannerAction(page: BannerPageModel)
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation
|
||||
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
interface PromotionBannersMixinFactory {
|
||||
fun create(source: BannersSource, coroutineScope: CoroutineScope): PromotionBannersMixin
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation
|
||||
|
||||
import io.novafoundation.nova.common.base.BaseFragment
|
||||
import io.novafoundation.nova.common.base.BaseViewModel
|
||||
import io.novafoundation.nova.common.domain.ExtendedLoadingState
|
||||
import io.novafoundation.nova.common.domain.dataOrNull
|
||||
import io.novafoundation.nova.common.domain.isLoading
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.BannerPagerView
|
||||
|
||||
context(BaseFragment<T, *>)
|
||||
fun <T : BaseViewModel> PromotionBannersMixin.bindWithAdapter(
|
||||
adapter: PromotionBannerAdapter,
|
||||
onSubmitList: () -> Unit = {}
|
||||
) {
|
||||
adapter.setCallback(object : BannerPagerView.Callback {
|
||||
override fun onBannerClicked(page: BannerPageModel) {
|
||||
this@bindWithAdapter.startBannerAction(page)
|
||||
}
|
||||
|
||||
override fun onBannerClosed(page: BannerPageModel) {
|
||||
this@bindWithAdapter.closeBanner(page)
|
||||
}
|
||||
})
|
||||
|
||||
bannersFlow.observe {
|
||||
adapter.show(it is ExtendedLoadingState.Loaded && it.data.isNotEmpty())
|
||||
adapter.setBanners(it.dataOrNull.orEmpty())
|
||||
onSubmitList()
|
||||
}
|
||||
}
|
||||
|
||||
context(BaseFragment<T, *>)
|
||||
fun <T : BaseViewModel> PromotionBannersMixin.bind(
|
||||
view: BannerPagerView
|
||||
) {
|
||||
view.setClosable(false)
|
||||
|
||||
bannersFlow.observe {
|
||||
view.setLoadingState(it.isLoading)
|
||||
view.setBanners(it.dataOrNull.orEmpty())
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.source
|
||||
|
||||
import io.novafoundation.nova.feature_banners_api.BuildConfig
|
||||
import io.novafoundation.nova.feature_banners_api.domain.PromotionBanner
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface BannersSourceFactory {
|
||||
fun create(bannersUrl: String, localisationUrl: String): BannersSource
|
||||
}
|
||||
|
||||
interface BannersSource {
|
||||
fun observeBanners(): Flow<List<PromotionBanner>>
|
||||
}
|
||||
|
||||
fun BannersSourceFactory.forDirectory(directory: String): BannersSource {
|
||||
val baseDirectory = "${BuildConfig.BANNERS_BASE_DIRECTORY}/$directory"
|
||||
val suffix = if (BuildConfig.DEBUG) "_dev" else ""
|
||||
val bannersUrl = "$baseDirectory/banners$suffix.json"
|
||||
val localisationsUrl = "$baseDirectory/localized$suffix"
|
||||
|
||||
return create(bannersUrl, localisationsUrl)
|
||||
}
|
||||
|
||||
fun BannersSourceFactory.dappsSource() = create(BuildConfig.DAPPS_BANNERS_URL, BuildConfig.DAPPS_BANNERS_LOCALISATION_URL)
|
||||
|
||||
fun BannersSourceFactory.assetsSource() = create(BuildConfig.ASSETS_BANNERS_URL, BuildConfig.ASSETS_BANNERS_LOCALISATION_URL)
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view
|
||||
|
||||
import android.content.Context
|
||||
import android.view.MotionEvent
|
||||
import android.view.VelocityTracker
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.Scroller
|
||||
import io.novafoundation.nova.common.utils.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.min
|
||||
|
||||
private const val MIN_FLING_VELOCITY = 1000 // px/s
|
||||
|
||||
enum class PageOffset(val scrollDirection: Int, val pageOffset: Int) {
|
||||
NEXT(-1, 1), PREVIOUS(1, -1), SAME(0, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* BannerPagerScrollController tracks the banner scroll and calls a callback, passing a value from -1 to 1 depending on the scroll direction.
|
||||
* Upon release, it animates the scroll to the selected page.
|
||||
*
|
||||
* - When the scroll animation ends, we replace the Scroller with a new one to reset its scroll value to 0. This is necessary to support infinite scrolling and to remain in the range from -1 to 1.
|
||||
*/
|
||||
class BannerPagerScrollController(private val context: Context, private val callback: ScrollCallback) {
|
||||
|
||||
interface ScrollCallback {
|
||||
fun onScrollToPage(pageOffset: Float, toPage: PageOffset)
|
||||
|
||||
fun onScrollFinished(pageOffset: PageOffset)
|
||||
|
||||
fun invalidateScroll()
|
||||
}
|
||||
|
||||
private val scrollTracking: ScrollTracking = ScrollTracking()
|
||||
private var containerWidth = 0
|
||||
|
||||
private var isTouchable: Boolean = true
|
||||
|
||||
val minimumScrollDuration = 200
|
||||
|
||||
fun setTouchable(touchable: Boolean) {
|
||||
isTouchable = touchable
|
||||
}
|
||||
|
||||
fun setContainerWidth(width: Int) {
|
||||
this.containerWidth = width
|
||||
}
|
||||
|
||||
fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (!isTouchable) return false
|
||||
|
||||
return when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
scrollTracking.onStartScroll(context, event.x)
|
||||
scrollTracking.addMovement(event)
|
||||
true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
scrollTracking.addMovement(event)
|
||||
scrollTracking.updateLastX(event.x)
|
||||
|
||||
val scrollDirection = scrollTracking.getPageOffset()
|
||||
|
||||
callback.onScrollToPage(currentPageOffset(), scrollDirection)
|
||||
|
||||
val isHorizontalScroll = scrollTracking.eventDx().absoluteValue > 8.dp(context)
|
||||
isHorizontalScroll
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
scrollTracking.addMovement(event)
|
||||
|
||||
val velocity = scrollTracking.getVelocity()
|
||||
handleSwipe(scrollTracking.currentScroll(), velocity)
|
||||
scrollTracking.recycle()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun computeScroll() {
|
||||
if (scrollTracking.computeScroll()) {
|
||||
val pageOffset = scrollTracking.getPageOffset()
|
||||
callback.onScrollToPage(currentPageOffset(), pageOffset)
|
||||
callback.invalidateScroll()
|
||||
|
||||
if (scrollTracking.state == ScrollTracking.State.IDLE) {
|
||||
callback.onScrollFinished(pageOffset)
|
||||
scrollTracking.onFinishScroll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSwipe(dx: Float, velocityX: Float) {
|
||||
val isVelocityEnough = abs(velocityX) > MIN_FLING_VELOCITY
|
||||
val isSwipeEnough = abs(dx) > containerWidth / 4
|
||||
|
||||
val shouldFling = isVelocityEnough || isSwipeEnough
|
||||
|
||||
val pageOffset = when {
|
||||
dx < 0 && shouldFling -> PageOffset.NEXT
|
||||
dx > 0 && shouldFling -> PageOffset.PREVIOUS
|
||||
else -> PageOffset.SAME
|
||||
}
|
||||
|
||||
smoothScrollToPage(pageOffset, velocityX)
|
||||
}
|
||||
|
||||
private fun smoothScrollToPage(page: PageOffset, velocityX: Float) {
|
||||
val scrollWidth = page.scrollDirection * containerWidth
|
||||
val duration = computeScrollDuration(abs(velocityX))
|
||||
scrollTracking.smoothScrollToPosition(context, scrollWidth, duration)
|
||||
callback.invalidateScroll()
|
||||
}
|
||||
|
||||
private fun computeScrollDuration(velocityX: Float): Int {
|
||||
val baseDuration = minimumScrollDuration
|
||||
val maxDuration = 600
|
||||
return (baseDuration - min(velocityX / 2, baseDuration.toFloat())).toInt().coerceIn(150, maxDuration)
|
||||
}
|
||||
|
||||
private fun currentPageOffset(): Float {
|
||||
return try {
|
||||
val scrollOffset = scrollTracking.currentScroll() / containerWidth
|
||||
|
||||
// We flip scroll to get page offset
|
||||
-scrollOffset.coerceIn(-1f, 1f)
|
||||
} catch (e: ArithmeticException) {
|
||||
0f
|
||||
}
|
||||
}
|
||||
|
||||
fun swipeToPage(next: PageOffset) {
|
||||
smoothScrollToPage(next, 0f)
|
||||
}
|
||||
|
||||
fun isIdle(): Boolean {
|
||||
return scrollTracking.state == ScrollTracking.State.IDLE
|
||||
}
|
||||
}
|
||||
|
||||
private class ScrollTracking {
|
||||
|
||||
enum class State {
|
||||
IDLE, SCROLLING, SCROLLING_RELEASED
|
||||
}
|
||||
|
||||
var state = State.IDLE
|
||||
private set
|
||||
|
||||
private var currentScroll = 0f
|
||||
private var velocityTracker: VelocityTracker? = null
|
||||
private var scroller: Scroller? = null
|
||||
|
||||
private var eventDownX = 0f
|
||||
private var eventLastX = 0f
|
||||
|
||||
fun getVelocity(): Float {
|
||||
velocityTracker?.computeCurrentVelocity(MIN_FLING_VELOCITY)
|
||||
return velocityTracker?.xVelocity ?: return 0f
|
||||
}
|
||||
|
||||
fun addMovement(event: MotionEvent) {
|
||||
velocityTracker?.addMovement(event)
|
||||
}
|
||||
|
||||
fun updateLastX(x: Float) {
|
||||
val dx = x - eventLastX
|
||||
currentScroll += dx
|
||||
|
||||
eventLastX = x
|
||||
}
|
||||
|
||||
fun currentScroll(): Float {
|
||||
return currentScroll
|
||||
}
|
||||
|
||||
fun eventDx(): Float {
|
||||
return eventLastX - eventDownX
|
||||
}
|
||||
|
||||
fun computeScroll(): Boolean {
|
||||
val scroller = scroller ?: return false
|
||||
val scrollComputed = scroller.computeScrollOffset()
|
||||
|
||||
currentScroll = scroller.currX.toFloat()
|
||||
|
||||
if (scrollComputed && scroller.isFinished) { // Call only once when scroll is finished
|
||||
state = State.IDLE
|
||||
}
|
||||
|
||||
return scrollComputed
|
||||
}
|
||||
|
||||
fun onStartScroll(context: Context, eventX: Float) {
|
||||
scroller?.forceFinished(true)
|
||||
|
||||
if (state == State.IDLE) {
|
||||
state = State.SCROLLING
|
||||
currentScroll = 0f
|
||||
}
|
||||
|
||||
eventDownX = eventX
|
||||
eventLastX = eventX
|
||||
|
||||
velocityTracker = VelocityTracker.obtain()
|
||||
createScroller(context)
|
||||
}
|
||||
|
||||
fun onFinishScroll() {
|
||||
currentScroll = 0f
|
||||
scroller = null
|
||||
eventDownX = 0f
|
||||
eventLastX = 0f
|
||||
}
|
||||
|
||||
fun recycle() {
|
||||
velocityTracker?.recycle()
|
||||
}
|
||||
|
||||
fun smoothScrollToPosition(context: Context, toPosition: Int, duration: Int) {
|
||||
if (state == State.IDLE) {
|
||||
createScroller(context)
|
||||
}
|
||||
|
||||
state = State.SCROLLING_RELEASED
|
||||
val currentPosition = currentScroll.toInt()
|
||||
scroller?.startScroll(currentPosition, 0, toPosition - currentPosition, 0, duration)
|
||||
}
|
||||
|
||||
fun getPageOffset(): PageOffset {
|
||||
val currentScroll = currentScroll.toInt()
|
||||
|
||||
return when {
|
||||
currentScroll < 0 -> PageOffset.NEXT
|
||||
currentScroll > 0 -> PageOffset.PREVIOUS
|
||||
else -> PageOffset.SAME
|
||||
}
|
||||
}
|
||||
|
||||
private fun createScroller(context: Context) {
|
||||
scroller = Scroller(context, DecelerateInterpolator())
|
||||
}
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.view.isVisible
|
||||
import io.novafoundation.nova.common.utils.ViewClickGestureDetector
|
||||
import io.novafoundation.nova.common.utils.indexOfOrNull
|
||||
import io.novafoundation.nova.common.utils.inflater
|
||||
import io.novafoundation.nova.common.utils.mapToSet
|
||||
import io.novafoundation.nova.feature_banners_api.databinding.ViewPagerBannerBinding
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.BannerPageModel
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.ContentSwitchingController
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.getContentSwitchingController
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.getImageSwitchingController
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* View for viewing banner pages, supporting infinite scrolling
|
||||
* BannerPagerScrollController tracks banner scrolling and triggers a callback, passing a value from -1 to 1 depending on the scroll direction
|
||||
* Animates the scroll to the selected page upon release
|
||||
*/
|
||||
class BannerPagerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr), BannerPagerScrollController.ScrollCallback {
|
||||
|
||||
private val binder = ViewPagerBannerBinding.inflate(inflater(), this)
|
||||
|
||||
private val scrollInterpolator = DecelerateInterpolator()
|
||||
|
||||
private val scrollController = BannerPagerScrollController(context, this)
|
||||
|
||||
private val gestureDetector = ViewClickGestureDetector(this)
|
||||
|
||||
private var currentPage = 0
|
||||
private var pages: MutableList<BannerPageModel> = mutableListOf()
|
||||
|
||||
private val canScroll: Boolean
|
||||
get() = !closeAnimator.isRunning && pages.size > 1
|
||||
|
||||
private val canRunScrollAnimation: Boolean
|
||||
get() = canScroll && scrollController.isIdle()
|
||||
|
||||
private val contentController = getContentSwitchingController(scrollInterpolator)
|
||||
|
||||
private val backgroundSwitchingController = getImageSwitchingController(scrollInterpolator)
|
||||
|
||||
private var autoSwipeCallbackAdded = false
|
||||
private val autoSwipeDelay = 3.seconds.inWholeMilliseconds
|
||||
private val autoSwipeCallback = object : Runnable {
|
||||
override fun run() {
|
||||
if (canRunScrollAnimation) {
|
||||
scrollController.swipeToPage(PageOffset.NEXT)
|
||||
handler?.postDelayed(this, autoSwipeDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var callback: Callback? = null
|
||||
|
||||
private val closeAnimator = ValueAnimator().apply {
|
||||
interpolator = scrollInterpolator
|
||||
duration = scrollController.minimumScrollDuration.toLong()
|
||||
}
|
||||
|
||||
val isClosable: Boolean
|
||||
get() = binder.pagerBannerClose.isVisible
|
||||
|
||||
init {
|
||||
contentController.attachToParent(binder.pagerBannerContent)
|
||||
backgroundSwitchingController.attachToParent(binder.pagerBannerBackground)
|
||||
|
||||
binder.pagerBannerClose.setOnClickListener { closeCurrentPage() }
|
||||
}
|
||||
|
||||
fun setLoadingState(loading: Boolean) {
|
||||
binder.pagerBannerShimmering.isVisible = loading
|
||||
binder.pagerBannerCardView.isVisible = !loading
|
||||
binder.pagerBannerIndicators.isVisible = !loading
|
||||
binder.pagerBannerContent.isVisible = !loading
|
||||
}
|
||||
|
||||
fun setBanners(banners: List<BannerPageModel>) {
|
||||
val newIds = banners.mapToSet { it.id }
|
||||
val currentIds = pages.mapToSet { it.id }
|
||||
if (newIds == currentIds) return // Check that pages not changed
|
||||
|
||||
this.pages.clear()
|
||||
this.pages.addAll(banners)
|
||||
binder.pagerBannerIndicators.setPagesSize(pages.size)
|
||||
|
||||
contentController.setPayloads(pages.map { ContentSwitchingController.Payload(it.title, it.subtitle, it.image) })
|
||||
backgroundSwitchingController.setPayloads(pages.map { it.background })
|
||||
|
||||
if (pages.isNotEmpty()) {
|
||||
selectPageImmediately(pages.first())
|
||||
rerunAutoSwipe()
|
||||
}
|
||||
}
|
||||
|
||||
fun setClosable(closable: Boolean) {
|
||||
binder.pagerBannerClose.isVisible = closable
|
||||
}
|
||||
|
||||
fun closeCurrentPage() {
|
||||
if (pages.size == 1) {
|
||||
callback?.onBannerClosed(pages.first())
|
||||
|
||||
return // Don't run animation to let close banner from outside
|
||||
}
|
||||
|
||||
if (isClosable && canRunScrollAnimation) {
|
||||
val isLastPageAfterClose = pages.size == 2 // 2 pages befo
|
||||
val nextPage = (currentPage + 1).wrapPage()
|
||||
|
||||
scrollController.setTouchable(false)
|
||||
stopAutoSwipe()
|
||||
|
||||
closeAnimator.removeAllListeners()
|
||||
closeAnimator.removeAllUpdateListeners()
|
||||
|
||||
closeAnimator.setFloatValues(0f, 1f)
|
||||
closeAnimator.addUpdateListener {
|
||||
if (isLastPageAfterClose) {
|
||||
binder.pagerBannerIndicators.alpha = 1f - it.animatedFraction
|
||||
} else {
|
||||
binder.pagerBannerIndicators.setCloseAnimationProgress(it.animatedFraction, currentPage, nextPage)
|
||||
}
|
||||
|
||||
contentController.setAnimationState(it.animatedFraction, currentPage, nextPage)
|
||||
backgroundSwitchingController.setAnimationState(it.animatedFraction, currentPage, nextPage)
|
||||
}
|
||||
closeAnimator.doOnEnd {
|
||||
closePage(currentPage)
|
||||
invalidateScrolling()
|
||||
startAutoSwipe()
|
||||
}
|
||||
closeAnimator.start()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCallback(callback: Callback?) {
|
||||
this.callback = callback
|
||||
}
|
||||
|
||||
private fun closePage(index: Int) {
|
||||
contentController.removePageAt(index)
|
||||
backgroundSwitchingController.removePageAt(index)
|
||||
val closedPage = pages.removeAt(index)
|
||||
currentPage = index.wrapPage()
|
||||
binder.pagerBannerIndicators.setPagesSize(pages.size)
|
||||
binder.pagerBannerIndicators.selectIndicatorInstantly(currentPage)
|
||||
contentController.showPageImmediately(currentPage)
|
||||
backgroundSwitchingController.showPageImmediately(currentPage)
|
||||
|
||||
callback?.onBannerClosed(closedPage)
|
||||
}
|
||||
|
||||
private fun invalidateScrolling() {
|
||||
scrollController.setTouchable(pages.size > 1)
|
||||
}
|
||||
|
||||
override fun onScrollToPage(pageOffset: Float, toPage: PageOffset) {
|
||||
if (!canScroll) return
|
||||
|
||||
val nextPage = (currentPage + toPage.pageOffset).wrapPage()
|
||||
|
||||
binder.pagerBannerIndicators.setAnimationProgress(pageOffset.absoluteValue, currentPage, nextPage)
|
||||
|
||||
contentController.setAnimationState(pageOffset, currentPage, nextPage)
|
||||
backgroundSwitchingController.setAnimationState(pageOffset, currentPage, nextPage)
|
||||
}
|
||||
|
||||
override fun onScrollFinished(pageOffset: PageOffset) {
|
||||
this.currentPage = (this.currentPage + pageOffset.pageOffset).wrapPage()
|
||||
}
|
||||
|
||||
override fun invalidateScroll() {
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
gestureDetector.onTouchEvent(event)
|
||||
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
startAutoSwipe()
|
||||
} else {
|
||||
stopAutoSwipe()
|
||||
}
|
||||
|
||||
val isScrollIntercepted = scrollController.onTouchEvent(event)
|
||||
parent.requestDisallowInterceptTouchEvent(isScrollIntercepted)
|
||||
return isScrollIntercepted
|
||||
}
|
||||
|
||||
override fun performClick(): Boolean {
|
||||
callback?.onBannerClicked(pages[currentPage])
|
||||
return true
|
||||
}
|
||||
|
||||
override fun computeScroll() {
|
||||
scrollController.computeScroll()
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
scrollController.setContainerWidth(width)
|
||||
}
|
||||
|
||||
private fun selectPageImmediately(page: BannerPageModel) {
|
||||
val index = pages.indexOfOrNull(page) ?: return
|
||||
|
||||
contentController.showPageImmediately(index)
|
||||
backgroundSwitchingController.showPageImmediately(index)
|
||||
|
||||
binder.pagerBannerIndicators.selectIndicatorInstantly(index)
|
||||
}
|
||||
|
||||
private fun rerunAutoSwipe() {
|
||||
stopAutoSwipe()
|
||||
startAutoSwipe()
|
||||
}
|
||||
|
||||
private fun stopAutoSwipe() {
|
||||
if (autoSwipeCallbackAdded) {
|
||||
removeCallbacks(autoSwipeCallback)
|
||||
|
||||
autoSwipeCallbackAdded = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAutoSwipe() {
|
||||
if (autoSwipeCallbackAdded) return
|
||||
if (!canScroll) return
|
||||
postDelayed(autoSwipeCallback, autoSwipeDelay)
|
||||
|
||||
autoSwipeCallbackAdded = true
|
||||
}
|
||||
|
||||
private fun Int.wrapPage(): Int {
|
||||
val min = 0
|
||||
val max = pages.size - 1
|
||||
if (max == 0) return 0
|
||||
if (max < 0) return this
|
||||
|
||||
return when {
|
||||
this > max -> 0
|
||||
this < min -> max
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
|
||||
fun onBannerClicked(page: BannerPageModel)
|
||||
|
||||
fun onBannerClosed(page: BannerPageModel)
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view
|
||||
|
||||
import android.animation.ArgbEvaluator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import io.novafoundation.nova.common.R
|
||||
import io.novafoundation.nova.common.utils.dpF
|
||||
import io.novafoundation.nova.common.utils.getFromTheEndOrNull
|
||||
import io.novafoundation.nova.common.utils.isLast
|
||||
import io.novafoundation.nova.common.utils.isNotLast
|
||||
|
||||
private const val NO_PAGE = -1
|
||||
|
||||
private class Indicator(var size: Float, var marginToNext: Float, @ColorInt var color: Int)
|
||||
|
||||
class PageIndicatorView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private var pagesCount = 0
|
||||
private val indicators = mutableListOf<Indicator>()
|
||||
|
||||
private val indicatorColor = context.getColor(R.color.icon_inactive)
|
||||
private val goneIndicatorColor = Color.TRANSPARENT
|
||||
private val indicatorRadius = 3.dpF
|
||||
private val indicatorWidth = indicatorRadius * 2
|
||||
private val indicatorMargin = 12.dpF
|
||||
private val indicatorFullLength = 14.dpF
|
||||
|
||||
private var indicatorsBoxWidth = 0f
|
||||
|
||||
private val argbEvaluator = ArgbEvaluator()
|
||||
|
||||
private val indicatorPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
.apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = indicatorWidth
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
fun setPagesSize(size: Int) {
|
||||
pagesCount = size
|
||||
indicators.clear()
|
||||
if (size > 0) {
|
||||
indicators.addAll(List(size) { Indicator(0f, indicatorMargin, indicatorColor) })
|
||||
selectIndicatorInstantly(0)
|
||||
}
|
||||
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun selectIndicatorInstantly(pageIndex: Int) {
|
||||
setAnimationProgress(1f, fromPage = NO_PAGE, toPage = pageIndex)
|
||||
}
|
||||
|
||||
fun setAnimationProgress(offset: Float, fromPage: Int, toPage: Int) {
|
||||
setAnimationProgressInternal(offset.coerceIn(0f, 1f), fromPage, toPage, removeFrom = false)
|
||||
}
|
||||
|
||||
fun setCloseAnimationProgress(offset: Float, closingPage: Int, nextPage: Int) {
|
||||
setAnimationProgressInternal(offset.coerceIn(0f, 1f), closingPage, nextPage, removeFrom = true)
|
||||
}
|
||||
|
||||
private fun setAnimationProgressInternal(offset: Float, fromIndex: Int, toIndex: Int, removeFrom: Boolean) {
|
||||
if (indicators.size <= 1) {
|
||||
hideIndicators()
|
||||
invalidate()
|
||||
return
|
||||
}
|
||||
|
||||
clearIndicatorSizeParams()
|
||||
increaseSizeForAnimationOffset(toIndex, offset)
|
||||
decreaseSizeForAnimationOffset(fromIndex, offset, removeFrom)
|
||||
|
||||
calculateIndicatorsBoxWidth()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun calculateIndicatorsBoxWidth() {
|
||||
var newIndicatorBoxWidth = 0f
|
||||
newIndicatorBoxWidth += indicatorRadius * 2 // Add start and end radius of indicator
|
||||
indicators.forEach {
|
||||
newIndicatorBoxWidth += it.size + it.marginToNext
|
||||
}
|
||||
indicatorsBoxWidth = newIndicatorBoxWidth
|
||||
}
|
||||
|
||||
private fun increaseSizeForAnimationOffset(indicatorIndex: Int, offset: Float) {
|
||||
val indicator = indicators.getOrNull(indicatorIndex) ?: return
|
||||
indicator.size = offset * indicatorFullLength
|
||||
|
||||
if (indicators.isNotLast(indicator)) {
|
||||
indicator.marginToNext = indicatorMargin
|
||||
}
|
||||
}
|
||||
|
||||
private fun decreaseSizeForAnimationOffset(indicatorIndex: Int, offset: Float, isRemovingAnimation: Boolean) {
|
||||
val indicator = indicators.getOrNull(indicatorIndex) ?: return
|
||||
|
||||
indicator.size = indicatorFullLength - offset * indicatorFullLength
|
||||
|
||||
when {
|
||||
indicators.isLast(indicator) && isRemovingAnimation -> {
|
||||
val nextLastIndicator = indicators.getFromTheEndOrNull(1)
|
||||
nextLastIndicator?.marginToNext = indicatorMargin - offset * indicatorMargin
|
||||
indicator.marginToNext = 0f
|
||||
}
|
||||
|
||||
indicators.isNotLast(indicator) -> {
|
||||
val marginToNext = if (isRemovingAnimation) indicatorMargin - offset * indicatorMargin else indicatorMargin
|
||||
indicator.marginToNext = marginToNext
|
||||
}
|
||||
}
|
||||
|
||||
val endColor = if (isRemovingAnimation) goneIndicatorColor else indicatorColor
|
||||
indicator.color = argbEvaluator.evaluate(offset, indicatorColor, endColor) as Int
|
||||
}
|
||||
|
||||
private fun clearIndicatorSizeParams() {
|
||||
indicators.forEach {
|
||||
it.size = 0f
|
||||
it.marginToNext = indicatorMargin
|
||||
}
|
||||
indicators.lastOrNull()?.marginToNext = 0f
|
||||
}
|
||||
|
||||
private fun hideIndicators() {
|
||||
indicators.forEach { it.color = goneIndicatorColor }
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val startSpace = (measuredWidth - indicatorsBoxWidth) / 2
|
||||
val startMarginIndicator = indicatorRadius
|
||||
var lastEnd = startSpace + startMarginIndicator
|
||||
|
||||
indicators.forEachIndexed { index, indicator ->
|
||||
indicatorPaint.color = indicator.color
|
||||
val start = lastEnd
|
||||
lastEnd = start + indicator.size
|
||||
canvas.drawLine(
|
||||
start,
|
||||
height / 2f,
|
||||
lastEnd,
|
||||
height / 2f,
|
||||
indicatorPaint
|
||||
)
|
||||
lastEnd += indicator.marginToNext
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import io.novafoundation.nova.common.utils.RoundCornersOutlineProvider
|
||||
import io.novafoundation.nova.common.utils.dpF
|
||||
import io.novafoundation.nova.common.utils.inflater
|
||||
import io.novafoundation.nova.feature_banners_api.databinding.ViewPagerBannerPageBinding
|
||||
|
||||
class PageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val binder = ViewPagerBannerPageBinding.inflate(inflater(), this)
|
||||
|
||||
val title: TextView
|
||||
get() = binder.pagerBannerTitle
|
||||
|
||||
val subtitle: TextView
|
||||
get() = binder.pagerBannerSubtitle
|
||||
|
||||
val image: ImageView
|
||||
get() = binder.pagerBannerImage
|
||||
|
||||
private val roundCornersOutlineProvider = RoundCornersOutlineProvider(12.dpF)
|
||||
|
||||
init {
|
||||
outlineProvider = roundCornersOutlineProvider
|
||||
clipToOutline = true
|
||||
}
|
||||
|
||||
fun setClipMargin(rect: Rect) {
|
||||
roundCornersOutlineProvider.setMargin(rect)
|
||||
invalidateOutline()
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher
|
||||
|
||||
import android.graphics.Rect
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.ClipableImage
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.PageView
|
||||
|
||||
class ContentSwitchingController(
|
||||
private val clipMargin: Rect,
|
||||
rightSwitchingAnimators: InOutAnimators,
|
||||
leftSwitchingAnimators: InOutAnimators,
|
||||
private val viewFactory: () -> PageView
|
||||
) : SwitchingController<ContentSwitchingController.Payload, PageView>(
|
||||
rightSwitchingAnimators = rightSwitchingAnimators,
|
||||
leftSwitchingAnimators = leftSwitchingAnimators
|
||||
) {
|
||||
|
||||
class Payload(val title: String, val subtitle: String, val clipableImage: ClipableImage)
|
||||
|
||||
override fun setPayloadsInternal(payloads: List<Payload>): List<PageView> {
|
||||
return payloads.map { payload ->
|
||||
val view = viewFactory()
|
||||
view.title.text = payload.title
|
||||
view.subtitle.text = payload.subtitle
|
||||
|
||||
view.layoutParams = ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, ConstraintLayout.LayoutParams.MATCH_PARENT)
|
||||
|
||||
val imageModel = payload.clipableImage
|
||||
if (view.image.drawable != imageModel.drawable) {
|
||||
view.image.setImageDrawable(imageModel.drawable)
|
||||
}
|
||||
|
||||
if (imageModel.clip) {
|
||||
view.setClipMargin(clipMargin)
|
||||
} else {
|
||||
view.setClipMargin(Rect())
|
||||
}
|
||||
|
||||
view
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.widget.ImageView
|
||||
|
||||
class ImageSwitchingController(
|
||||
rightSwitchingAnimators: InOutAnimators,
|
||||
leftSwitchingAnimators: InOutAnimators,
|
||||
private val imageViewFactory: () -> ImageView
|
||||
) : SwitchingController<Drawable, ImageView>(rightSwitchingAnimators = rightSwitchingAnimators, leftSwitchingAnimators = leftSwitchingAnimators) {
|
||||
|
||||
override fun setPayloadsInternal(payloads: List<Drawable>): List<ImageView> {
|
||||
return payloads.map {
|
||||
val image = imageViewFactory()
|
||||
image.setImageDrawable(it)
|
||||
image
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Interpolator
|
||||
import android.widget.ImageView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams
|
||||
import io.novafoundation.nova.common.utils.dp
|
||||
import io.novafoundation.nova.common.utils.dpF
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.BannerPagerView
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.PageView
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.AlphaInterpolatedAnimator
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.CompoundInterpolatedAnimator
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.FractionAnimator
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.InterpolationRange
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.OffsetXInterpolatedAnimator
|
||||
|
||||
private const val OFFSET = 36
|
||||
|
||||
fun BannerPagerView.getImageSwitchingController(interpolator: Interpolator): ImageSwitchingController {
|
||||
return ImageSwitchingController(
|
||||
rightSwitchingAnimators = alphaAnimator(interpolator),
|
||||
leftSwitchingAnimators = alphaAnimator(interpolator),
|
||||
imageViewFactory = {
|
||||
ImageView(context).apply {
|
||||
scaleType = ImageView.ScaleType.FIT_XY
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun alphaAnimator(interpolator: Interpolator): InOutAnimators {
|
||||
return InOutAnimators(
|
||||
inAnimator = AlphaInterpolatedAnimator(interpolator, InterpolationRange(0f, 1f)),
|
||||
outAnimator = AlphaInterpolatedAnimator(interpolator, InterpolationRange(1f, 0f))
|
||||
)
|
||||
}
|
||||
|
||||
fun BannerPagerView.getContentSwitchingController(interpolator: Interpolator): ContentSwitchingController {
|
||||
return ContentSwitchingController(
|
||||
clipMargin = Rect(0, 8.dp, 0, 8.dp),
|
||||
rightSwitchingAnimators = getRightAnimator(interpolator),
|
||||
leftSwitchingAnimators = getLeftAnimator(interpolator),
|
||||
viewFactory = {
|
||||
PageView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun BannerPagerView.getRightAnimator(interpolator: Interpolator) = InOutAnimators(
|
||||
inAnimator = getContentAnimator(
|
||||
interpolator = interpolator,
|
||||
offsetRange = InterpolationRange(from = OFFSET.dpF, to = 0f),
|
||||
alphaRange = InterpolationRange(from = 0f, to = 1f)
|
||||
),
|
||||
outAnimator = getContentAnimator(
|
||||
interpolator = interpolator,
|
||||
offsetRange = InterpolationRange(from = 0f, to = -OFFSET.dpF),
|
||||
alphaRange = InterpolationRange(from = 1f, to = 0f)
|
||||
)
|
||||
)
|
||||
|
||||
private fun BannerPagerView.getLeftAnimator(interpolator: Interpolator) = InOutAnimators(
|
||||
inAnimator = getContentAnimator(
|
||||
interpolator = interpolator,
|
||||
offsetRange = InterpolationRange(from = -OFFSET.dpF, to = 0f),
|
||||
alphaRange = InterpolationRange(from = 0f, to = 1f)
|
||||
),
|
||||
outAnimator = getContentAnimator(
|
||||
interpolator = interpolator,
|
||||
offsetRange = InterpolationRange(from = 0f, to = OFFSET.dpF),
|
||||
alphaRange = InterpolationRange(from = 1f, to = 0f)
|
||||
)
|
||||
)
|
||||
|
||||
private fun getContentAnimator(interpolator: Interpolator, offsetRange: InterpolationRange, alphaRange: InterpolationRange): FractionAnimator {
|
||||
return CompoundInterpolatedAnimator(
|
||||
OffsetXInterpolatedAnimator(interpolator, offsetRange),
|
||||
AlphaInterpolatedAnimator(interpolator, alphaRange)
|
||||
)
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import io.novafoundation.nova.common.utils.removed
|
||||
import io.novafoundation.nova.common.utils.setVisible
|
||||
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.FractionAnimator
|
||||
|
||||
class InOutAnimators(
|
||||
val inAnimator: FractionAnimator,
|
||||
val outAnimator: FractionAnimator
|
||||
)
|
||||
|
||||
abstract class SwitchingController<P, V : View>(
|
||||
private val rightSwitchingAnimators: InOutAnimators,
|
||||
private val leftSwitchingAnimators: InOutAnimators
|
||||
) {
|
||||
|
||||
private var parent: ViewGroup? = null
|
||||
private var views: List<V> = listOf()
|
||||
|
||||
protected abstract fun setPayloadsInternal(payloads: List<P>): List<V>
|
||||
|
||||
fun setPayloads(payloads: List<P>): List<V> {
|
||||
views = setPayloadsInternal(payloads)
|
||||
setViewsToParent()
|
||||
return views
|
||||
}
|
||||
|
||||
fun attachToParent(parent: ViewGroup) {
|
||||
this.parent = parent
|
||||
setViewsToParent()
|
||||
}
|
||||
|
||||
fun setAnimationState(animationOffset: Float, from: Int, to: Int) {
|
||||
if (from >= views.size || to >= views.size) return
|
||||
|
||||
if (from == to) {
|
||||
showPageImmediately(from)
|
||||
} else {
|
||||
val (currentView, nextView) = showPagesByIndex(from, to)
|
||||
val animators = when {
|
||||
animationOffset > 0 -> rightSwitchingAnimators
|
||||
else -> leftSwitchingAnimators
|
||||
}
|
||||
|
||||
animators.outAnimator.animate(currentView, animationOffset)
|
||||
animators.inAnimator.animate(nextView, animationOffset)
|
||||
}
|
||||
}
|
||||
|
||||
fun showPageImmediately(index: Int) {
|
||||
if (index >= views.size) return
|
||||
|
||||
val (page) = showPagesByIndex(index)
|
||||
rightSwitchingAnimators.outAnimator.animate(page, 0f)
|
||||
}
|
||||
|
||||
// Pay attention that after using this method removed view is still contains in its parents
|
||||
// We do it this way to have the same size of banners after remove a page
|
||||
fun removePageAt(pageToRemove: Int) {
|
||||
val removedView = views[pageToRemove]
|
||||
views = views.removed { removedView == it }
|
||||
removedView.isInvisible = true
|
||||
}
|
||||
|
||||
private fun showPagesByIndex(vararg indexes: Int): List<V> {
|
||||
views.forEachIndexed { index, view ->
|
||||
view.setVisible(index in indexes, falseState = View.INVISIBLE)
|
||||
}
|
||||
|
||||
return indexes.map { views[it] }
|
||||
}
|
||||
|
||||
private fun setViewsToParent() {
|
||||
parent?.removeAllViews()
|
||||
views.forEach {
|
||||
parent?.addView(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation
|
||||
|
||||
import android.view.View
|
||||
import android.view.animation.Interpolator
|
||||
|
||||
class AlphaInterpolatedAnimator(
|
||||
interpolator: Interpolator,
|
||||
val alphaRange: InterpolationRange
|
||||
) : InterpolatedAnimator(interpolator) {
|
||||
|
||||
override fun animateInternal(view: View, fraction: Float) {
|
||||
view.alpha = alphaRange.getValueFor(fraction)
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation
|
||||
|
||||
import android.view.View
|
||||
|
||||
class CompoundInterpolatedAnimator(
|
||||
private val animators: List<FractionAnimator>
|
||||
) : FractionAnimator {
|
||||
|
||||
constructor(vararg animators: FractionAnimator) : this(animators.toList())
|
||||
|
||||
override fun animate(view: View, fraction: Float) {
|
||||
animators.forEach { it.animate(view, fraction) }
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation
|
||||
|
||||
import android.view.View
|
||||
import android.view.animation.Interpolator
|
||||
import io.novafoundation.nova.common.utils.signum
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
interface FractionAnimator {
|
||||
|
||||
fun animate(view: View, fraction: Float)
|
||||
}
|
||||
|
||||
abstract class InterpolatedAnimator(
|
||||
private val interpolator: Interpolator
|
||||
) : FractionAnimator {
|
||||
|
||||
override fun animate(view: View, fraction: Float) {
|
||||
val interpolatedValue = interpolator.getInterpolation(fraction.absoluteValue) * fraction.signum()
|
||||
animateInternal(view, interpolatedValue)
|
||||
}
|
||||
|
||||
protected abstract fun animateInternal(view: View, fraction: Float)
|
||||
}
|
||||
|
||||
class InterpolationRange(val from: Float, val to: Float) {
|
||||
|
||||
fun getValueFor(fraction: Float): Float {
|
||||
return from + (to - from) * fraction.absoluteValue
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation
|
||||
|
||||
import android.view.View
|
||||
import android.view.animation.Interpolator
|
||||
|
||||
class OffsetXInterpolatedAnimator(
|
||||
interpolator: Interpolator,
|
||||
private val offsetRange: InterpolationRange,
|
||||
) : InterpolatedAnimator(interpolator) {
|
||||
|
||||
override fun animateInternal(view: View, fraction: Float) {
|
||||
view.translationX = offsetRange.getValueFor(fraction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<io.novafoundation.nova.feature_banners_api.presentation.view.BannerPagerView
|
||||
android:id="@+id/bannerPager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/pagerBannerCardView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginVertical="8dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:layout_constraintBottom_toBottomOf="@id/pagerBannerContent"
|
||||
app:layout_constraintEnd_toEndOf="@id/pagerBannerContent"
|
||||
app:layout_constraintStart_toStartOf="@id/pagerBannerContent"
|
||||
app:layout_constraintTop_toTopOf="@id/pagerBannerContent"
|
||||
app:strokeColor="@color/container_border"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/pagerBannerBackground"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:src="@color/text_secondary" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/pagerBannerShimmering"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
android:background="@drawable/bg_block_12"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:layout_editor_absoluteX="16dp"
|
||||
tools:visibility="visible">
|
||||
|
||||
<com.facebook.shimmer.ShimmerFrameLayout
|
||||
android:layout_width="168dp"
|
||||
android:layout_height="14dp"
|
||||
android:background="@drawable/bg_shimmering" />
|
||||
|
||||
<com.facebook.shimmer.ShimmerFrameLayout
|
||||
android:layout_width="125dp"
|
||||
android:layout_height="8dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@drawable/bg_shimmering" />
|
||||
|
||||
<com.facebook.shimmer.ShimmerFrameLayout
|
||||
android:layout_width="89dp"
|
||||
android:layout_height="8dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@drawable/bg_shimmering" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/pagerBannerContent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:clipToPadding="false"
|
||||
android:elevation="16dp"
|
||||
android:minHeight="96dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pagerBannerClose"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:elevation="17dp"
|
||||
android:padding="8dp"
|
||||
android:scaleType="centerInside"
|
||||
android:src="@drawable/ic_close"
|
||||
app:layout_constraintEnd_toEndOf="@id/pagerBannerContent"
|
||||
app:layout_constraintTop_toTopOf="@id/pagerBannerContent"
|
||||
app:tint="@color/icon_secondary" />
|
||||
|
||||
<io.novafoundation.nova.feature_banners_api.presentation.view.PageIndicatorView
|
||||
android:id="@+id/pagerBannerIndicators"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="6dp"
|
||||
android:layout_marginTop="8dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/pagerBannerCardView" />
|
||||
|
||||
</merge>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pagerBannerImage"
|
||||
android:layout_width="126dp"
|
||||
android:layout_height="0dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHeight_min="96dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@drawable/ic_android_nav_bar_dapps_active" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="20dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/pagerBannerImage"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pagerBannerTitle"
|
||||
style="@style/TextAppearance.NovaFoundation.SemiBold.Body"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="@color/text_primary"
|
||||
tools:text="Title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pagerBannerSubtitle"
|
||||
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="@color/text_primary"
|
||||
tools:text="Subtitle\nsecond line" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
||||
Reference in New Issue
Block a user