Skip to content

Instantly share code, notes, and snippets.

@nascimentodiego
Created August 7, 2026 18:19
Show Gist options
  • Select an option

  • Save nascimentodiego/c5d2996b0ce0d8f71daae4803bb23255 to your computer and use it in GitHub Desktop.

Select an option

Save nascimentodiego/c5d2996b0ce0d8f71daae4803bb23255 to your computer and use it in GitHub Desktop.
GlowImageCardPager
package com.dfn.animations.ui.compose.coverflow.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import com.dfn.animations.R
import com.dfn.animations.ui.compose.coverflow.PageIndicator
import com.dfn.animations.ui.compose.coverflow.brightnessColorMatrix
import kotlin.math.absoluteValue
/**
* HorizontalPager onde cada página é um card com blur + brilho, mas em vez
* de alternar por toque (como em [com.dfn.animations.ui.compose.coverflow.GlowImageCardBlur5]), os dois efeitos —
* junto com uma escala — acompanham continuamente a fração do gesto de
* arrastar (`currentPageOffsetFraction`)
*
* Para cada página, `pageOffset` vai de 0 (perfeitamente centralizada, a
* página selecionada) até 1 (a um page-width de distância do centro):
* - blur: 0.dp em pageOffset=0 até [cardBlurRadius] em pageOffset=1.
* - brilho: normal em pageOffset=0 até [maxBrightnessOffset] em pageOffset=1.
* - escala: 1f (tamanho normal) em pageOffset=0 até [minScale] em
* pageOffset=1.
*
* Centralização:
* o `contentPadding` do pager é calculado dinamicamente via
* [BoxWithConstraints] — `side = (maxWidth - cardWidth) / 2` — em vez de um
* valor fixo. Isso garante que SEMPRE existe espaço de sobra suficiente pra
* centralizar qualquer página, incluindo a primeira e a última, sem
* depender de `SnapPosition.Center` (que, combinado com um contentPadding
* fixo insuficiente, deixava um resíduo de fração nas páginas de borda —
* pequeno demais pra notar no indicador, mas visível como blur na imagem).
* Como consequência, o quanto do card vizinho "espia" nas laterais passa a
* depender da largura da tela (igual no CoverFlow), não é mais um valor
* fixo em dp.
*
* Sobre performance / graphicsLayer: a escala do card e a
* escala+translação+alpha do conteúdo (ícone/título/botão) usam
* [graphicsLayer], que lê `pageOffset` numa lambda de draw — isso por si só
* NÃO evita recomposição, porque `pageOffset` já é lido como valor comum
* (não numa lambda) mais acima, para alimentar [cardBlurRadius] e o
* [ColorFilter] de brilho, e esses dois **não têm** uma forma de ser lidos
* de forma adiada: `Modifier.blur()` recebe um `Dp` (valor), e o
* `colorFilter` do `GlideImage` é um parâmetro comum do composable, não uma
* propriedade de `GraphicsLayerScope`. Ou seja: a página inteira já
* recompõe a cada frame de scroll por causa do blur/brilho, então mover só
* a escala pra `graphicsLayer` não reduz esse custo — o valor real dessa
* mudança é consolidar tudo no mesmo padrão/local, não uma economia de
* recomposição por si só. Como só as páginas visíveis (não a lista inteira)
* recompõem, o custo prático continua pequeno.
*
* @param imageUrls urls das imagens, uma por página.
* @param cardWidth largura do card quando totalmente selecionado.
* @param cardHeight altura do card quando totalmente selecionado.
* @param cornerRadius raio dos cantos do card.
* @param cardBlurRadius raio do blur nas páginas totalmente não-selecionadas.
* @param maxBrightnessOffset brilho adicionado nas páginas totalmente
* não-selecionadas (escala 0–255; comece baixo, 30–50, pra evitar estourar
* pra branco — ver [com.dfn.animations.ui.compose.coverflow.GlowImageCardBlur5]).
* @param minScale escala das páginas totalmente não-selecionadas (a
* selecionada sempre fica em 1f).
* @param contentGradientStart fração da altura do card (0f topo, 1f base) a
* partir de onde o gradiente começa a escurecer — antes disso fica 100%
* transparente, revelando a foto sem nenhum escurecimento.
* @param contentGradientAlpha opacidade do preto na base do gradiente
* (1f = preto sólido).
* @param contentMaxScale escala do ícone/título/botão quando a página está
* totalmente não-selecionada (pageOffset=1) — na selecionada fica em 1f.
* @param contentMaxTranslateY quanto o ícone/título/botão desce quando a
* página está totalmente não-selecionada.
* @param contentMaxTranslateX quanto o ícone/título/botão se desloca em
* direção ao centro (para a direita, já que ficam alinhados à esquerda)
* quando a página está totalmente não-selecionada.
*/
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun GlowImageCardPager2(
imageUrls: List<String>,
modifier: Modifier = Modifier,
cardWidth: Dp = 328.dp,
cardHeight: Dp = 498.dp,
cornerRadius: Dp = 16.dp,
cardBlurRadius: Dp = 32.dp,
maxBrightnessOffset: Float = 45f,
minScale: Float = 0.8f,
contentGradientStart: Float = 0.45f,
contentGradientAlpha: Float = 0.85f,
contentMaxScale: Float = 1.3f,
contentMaxTranslateY: Dp = 40.dp,
contentMaxTranslateX: Dp = 24.dp,
) {
val pagerState = rememberPagerState(pageCount = { imageUrls.size })
val icons = listOf(R.drawable.ic_finance_mode, R.drawable.ic_cards_stacks, R.drawable.ic_edit)
val titles = listOf("Viagem dos sonhos", "Comprar um carro", "Personalize")
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
// Espaço exato pra sobrar de cada lado quando a página atual
// (largura = cardWidth) está centralizada no viewport — mesmo
// cálculo do CoverFlow.kt. Garante que centralizar é sempre
// possível, mesmo na primeira/última página.
val side = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
HorizontalPager(
state = pagerState,
// Fixed(cardWidth): cada página ocupa exatamente a largura do
// card, sem espaço vazio ao redor — assim elas ficam coladas
// umas nas outras e o contentPadding revela de fato um
// pedaço do card vizinho, não fundo vazio da própria página.
pageSize = PageSize.Fixed(cardWidth),
contentPadding = PaddingValues(horizontal = side),
pageSpacing = 0.dp,
modifier = Modifier.fillMaxWidth(),
) { page ->
// 0 = página selecionada (centralizada), 1 = um page-width
// de distância (a próxima página totalmente visível de
// cada lado).
val pageOffset =
((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
.absoluteValue
.coerceIn(0f, 1f)
val blurRadius = cardBlurRadius * pageOffset
val colorFilter = ColorFilter.colorMatrix(
brightnessColorMatrix(amount = pageOffset, maxOffset = maxBrightnessOffset)
)
// Antes: outer Box(fillMaxWidth) + inner Box(width/height
// fixos) aninhados. O outer não fazia nada — a página já
// chega com largura exata (PageSize.Fixed(cardWidth)) e o
// inner já define seu próprio tamanho, então o alignment do
// outer nunca tinha efeito. Removido: um nó de layout a
// menos por página, medido/posicionado a cada frame.
Box(
modifier = Modifier
.width(cardWidth)
.height(cardHeight)
// Escala do card consolidada em graphicsLayer (igual
// ao conteúdo abaixo) em vez de Modifier.scale() —
// mesmo efeito, só que no mesmo padrão usado no
// resto do arquivo. Não elimina a recomposição desta
// página (isso vem do blur/colorFilter, ver doc da
// função), mas evita um modifier extra.
.graphicsLayer {
val cardScale = lerp(1f, minScale, pageOffset)
scaleX = cardScale
scaleY = cardScale
}
.clip(RoundedCornerShape(cornerRadius)),
contentAlignment = Alignment.BottomStart,
) {
GlideImage(
model = imageUrls[page],
contentDescription = "Cover image $page",
colorFilter = colorFilter,
modifier = Modifier
.blur(
radius = blurRadius,
edgeTreatment = BlurredEdgeTreatment.Unbounded
),
contentScale = ContentScale.Crop,
)
// Gradiente transparente -> preto, só na parte de
// baixo do card (a partir de contentGradientStart),
// pra dar contraste ao ícone/título/botão sem
// escurecer a foto inteira.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0f to Color.Transparent,
contentGradientStart to Color.Transparent,
1f to Color.Black.copy(alpha = contentGradientAlpha),
)
)
)
Column(
modifier = Modifier
.padding(16.dp)
// graphicsLayer lê pageOffset numa lambda de
// draw — não adiciona recomposição nova (o custo
// já existe por causa do blur/colorFilter acima),
// mas também não precisa de nada além disso: as
// duas páginas envolvidas na transição (a que
// sai e a que entra) usam a mesma fórmula, só
// que com pageOffset em sentidos opostos, o que
// já produz o crossfade+movimento sincronizado.
.graphicsLayer {
val contentScale = lerp(1f, contentMaxScale, pageOffset)
scaleX = contentScale
scaleY = contentScale
translationX = contentMaxTranslateX.toPx() * pageOffset
translationY = contentMaxTranslateY.toPx() * pageOffset
alpha = 1f - pageOffset
},
horizontalAlignment = Alignment.Start
) {
Icon(
painterResource(icons[page]),
contentDescription = null,
tint = Color.White
)
Spacer(modifier = Modifier.height(4.dp))
Text(text = titles[page], fontSize = 24.sp, color = Color.White)
Spacer(modifier = Modifier.height(8.dp))
Button(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp), onClick = {}) {
Text("Escolher essa opção", fontSize = 14.sp)
}
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
PageIndicator(
pagerState = pagerState,
pageCount = imageUrls.size,
selectedColor = Color.DarkGray,
unselectedColor = Color.DarkGray.copy(alpha = 0.3f),
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment