Kształty w oknie tworzenia wiadomości

Za pomocą Compose możesz tworzyć kształty utworzone z poligonów. Możesz na przykład tworzyć te rodzaje kształtów:

Niebieski sześciokąt na środku obszaru rysowania
Rysunek 1. Przykłady różnych kształtów, które możesz tworzyć za pomocą biblioteki kształtów graficznych

Aby utworzyć niestandardowy zaokrąglony wielokąt w Compose, dodaj do app/build.gradle zależność graphics-shapes:

implementation "androidx.graphics:graphics-shapes:1.0.0-rc01"

Ta biblioteka umożliwia tworzenie kształtów złożonych z poligonów. Chociaż kształty wielokątne mają tylko proste krawędzie i ostre rogi, można w ich przypadku użyć opcjonalnych zaokrąglonych rogów. Dzięki temu możesz łatwo przekształcać jeden kształt w inny. Przekształcanie dowolnych kształtów jest trudne i zwykle stanowi problem na etapie projektowania. Ta biblioteka upraszcza ten proces, przekształcając kształty z podobnymi strukturami wielokątnymi.

Tworzenie wielokątów

Ten fragment kodu tworzy podstawowy wielokąt z 6 punktami w środku obszaru rysunku:

Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygon = RoundedPolygon(
                numVertices = 6,
                radius = size.minDimension / 2,
                centerX = size.width / 2,
                centerY = size.height / 2
            )
            val roundedPolygonPath = roundedPolygon.toPath().asComposePath()
            onDrawBehind {
                drawPath(roundedPolygonPath, color = Color.Blue)
            }
        }
        .fillMaxSize()
)

Niebieski sześciokąt na środku obszaru rysowania
Rysunek 2. Niebieski sześciokąt na środku obszaru rysowania.

W tym przykładzie biblioteka tworzy RoundedPolygon, która zawiera geometrię reprezentującą wymagany kształt. Aby narysować ten kształt w aplikacji Compose, musisz uzyskać z niego obiekt Path, aby móc narysować go w formie, którą Compose potrafi narysować.

Zaokrąglenie narożników wielokąta

Aby zaokrąglić narożniki wielokąta, użyj parametru CornerRounding. Ta funkcja przyjmuje 2 parametry: radiussmoothing. Każdy zaokrąglony róg składa się z 1–3 krzywych sześciennych, których środek ma kształt łuku kolistego, a dwie krzywe boczne („flankujące”) przechodzą od krawędzi kształtu do krzywej środkowej.

Promień

radius to promień koła użytego do zaokrąglenia wierzchołka.

Na przykład trójkąt z zaokrąglonymi rogami można utworzyć w ten sposób:

Trójkąt z zaokrąglonymi rogami
Rysunek 3. Trójkąt o zaokrąglonych rogach.
Promień zaokrąglenia r określa rozmiar zaokrąglonych rogów.
Rysunek 4. Promień zaokrąglenia r określa wielkość zaokrąglonych rogów.

Złagodzenie

Wygładzanie to czynnik, który określa, ile czasu zajmuje przejście od zaokrąglonego narożnika do krawędzi. Wartość 0 (niewygładzona, domyślna wartość dla CornerRounding) powoduje zaokrąglenie narożników w postaci koła. Niezerowy współczynnik wygładzania (maksymalnie 1,0) powoduje zaokrąglenie narożnika za pomocą 3 osobnych krzywych.

Współczynnik wygładzania 0 (niewygładzony) tworzy jedną krzywą kubiczną, która biegnie po okręgu wokół narożnika o określonym promieniu zaokrąglenia, jak w poprzednim przykładzie.
Rysunek 5. Współczynnik wygładzania 0 (niewygładzony) tworzy jedną krzywą kubiczną, która biegnie po okręgu wokół narożnika z zadanym promieniem zaokrąglania, jak w poprzednim przykładzie.
Niezerowy współczynnik wygładzania powoduje powstanie 3 krzywych krzywoliniowych, które zaokrąglają wierzchołek: wewnętrzna krzywa kołowa (jak poprzednio) oraz 2 krzywe boczne, które przechodzą między krzywą wewnętrzną a krawędziami wielokąta.
Rysunek 6. Niezerowy współczynnik wygładzania powoduje utworzenie 3 krzywych krzywoliniowych, które zaokrąglają wierzchołek: wewnętrznej krzywej kołowej (jak poprzednio) oraz 2 krzywych bocznych, które przechodzą między krzywą wewnętrzną a krawędziami wielokąta.

Na przykład poniższy fragment kodu pokazuje subtelną różnicę między ustawieniem wygładzania na 0 a 1:

Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygon = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2,
                centerX = size.width / 2,
                centerY = size.height / 2,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val roundedPolygonPath = roundedPolygon.toPath().asComposePath()
            onDrawBehind {
                drawPath(roundedPolygonPath, color = Color.Black)
            }
        }
        .size(100.dp)
)

Dwa czarne trójkąty pokazujące różnicę w parametrze wygładzania.
Rysunek 7. Dwa czarne trójkąty pokazujące różnicę w parametrze wygładzania.

Rozmiar i położenie

Domyślnie kształt jest tworzony z promieniem 1 wokół środka (0, 0). Ten promień reprezentuje odległość między środkiem a zewnętrzną krawędzią wielokąta, na którym opiera się kształt. Pamiętaj, że zaokrąglenie narożników powoduje powstanie mniejszego kształtu, ponieważ zaokrąglone narożniki będą bliżej środka niż zaokrąglone wierzchołki. Aby zmienić rozmiar wielokąta, dostosuj wartość radius. Aby dostosować pozycję, zmień centerX lub centerY wielokąta. Możesz też zmienić rozmiar, położenie i obrót obiektu za pomocą standardowych funkcji przekształcenia DrawScope, takich jak DrawScope#translate().

Kształty Morph

Obiekt Morph to nowy kształt reprezentujący animację między dwoma wielokątami. Aby przekształcić jeden kształt w drugi, utwórz 2 obiekty RoundedPolygonsMorph, które przyjmują te 2 ksztalty. Aby obliczyć kształt między kształtem początkowym a końcowym, podaj wartość progress z zakresu od 0 do 1, aby określić jego formę między kształtem początkowym (0) a końcowym (1):

Box(
    modifier = Modifier
        .drawWithCache {
            val triangle = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val square = RoundedPolygon(
                numVertices = 4,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f
            )

            val morph = Morph(start = triangle, end = square)
            val morphPath = morph
                .toPath(progress = 0.5f).asComposePath()

            onDrawBehind {
                drawPath(morphPath, color = Color.Black)
            }
        }
        .fillMaxSize()
)

W tym przykładzie postęp jest dokładnie w połowie drogi między dwoma kształtami (zaokrąglony trójkąt i kwadrat), co daje następujący wynik:

50% drogi między zaokrąglonym trójkątem a kwadratem
Rysunek 8. 50% drogi między zaokrąglonym trójkątem a kwadratem.

W większości przypadków przekształcanie jest wykonywane w ramach animacji, a nie tylko statycznych renderów. Aby animować przejście między tymi stanami, możesz użyć standardowych interfejsów API animacji w komponencie kompozycyjnym, aby zmieniać wartość postępu w czasie. Możesz na przykład bez końca animować przejście między tymi dwoma kształtami:

val infiniteAnimation = rememberInfiniteTransition(label = "infinite animation")
val morphProgress = infiniteAnimation.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        tween(500),
        repeatMode = RepeatMode.Reverse
    ),
    label = "morph"
)
Box(
    modifier = Modifier
        .drawWithCache {
            val triangle = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val square = RoundedPolygon(
                numVertices = 4,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f
            )

            val morph = Morph(start = triangle, end = square)
            val morphPath = morph
                .toPath(progress = morphProgress.value)
                .asComposePath()

            onDrawBehind {
                drawPath(morphPath, color = Color.Black)
            }
        }
        .fillMaxSize()
)

Nieskończone przejście od kwadratu do zaokrąglonego trójkąta
Rysunek 9. Nieskończone przejście od kwadratu do zaokrąglonego trójkąta.

Używanie wielokąta jako klipu

W komponowaniu często używa się modyfikatora clip, aby zmienić sposób renderowania kompozytowalności i korzystać z cieni rysowanych wokół obszaru przycinania:

fun RoundedPolygon.getBounds() = calculateBounds().let { Rect(it[0], it[1], it[2], it[3]) }
class RoundedPolygonShape(
    private val polygon: RoundedPolygon,
    private var matrix: Matrix = Matrix()
) : Shape {
    private var path = Path()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        path.rewind()
        path = polygon.toPath().asComposePath()
        matrix.reset()
        val bounds = polygon.getBounds()
        val maxDimension = max(bounds.width, bounds.height)
        matrix.scale(size.width / maxDimension, size.height / maxDimension)
        matrix.translate(-bounds.left, -bounds.top)

        path.transform(matrix)
        return Outline.Generic(path)
    }
}

Następnie możesz użyć wielokąta jako klipu, jak pokazano w tym fragmencie kodu:

val hexagon = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val clip = remember(hexagon) {
    RoundedPolygonShape(polygon = hexagon)
}
Box(
    modifier = Modifier
        .clip(clip)
        .background(MaterialTheme.colorScheme.secondary)
        .size(200.dp)
) {
    Text(
        "Hello Compose",
        color = MaterialTheme.colorScheme.onSecondary,
        modifier = Modifier.align(Alignment.Center)
    )
}

W efekcie:

Sześciokąt z napisem „hello compose” pośrodku.
Rysunek 10. Sześciokąt z napisem „Hello Compose” (Cześć, komponowanie) pośrodku.

Może to nie wyglądać zbyt różnie od poprzedniego renderowania, ale pozwala korzystać z innych funkcji w Compose. Możesz na przykład użyć tej techniki, aby wyciąć obraz i zastosować cień wokół wyciętego obszaru:

val hexagon = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val clip = remember(hexagon) {
    RoundedPolygonShape(polygon = hexagon)
}
Box(
    modifier = Modifier.fillMaxSize(),
    contentAlignment = Alignment.Center
) {
    Image(
        painter = painterResource(id = R.drawable.dog),
        contentDescription = "Dog",
        contentScale = ContentScale.Crop,
        modifier = Modifier
            .graphicsLayer {
                this.shadowElevation = 6.dp.toPx()
                this.shape = clip
                this.clip = true
                this.ambientShadowColor = Color.Black
                this.spotShadowColor = Color.Black
            }
            .size(200.dp)

    )
}

Pies w sześciokącie z cieniem na krawędziach
Rysunek 11. Kształt niestandardowy zastosowany jako klip.

Przycisk zmiany kształtu

Korzystając z biblioteki graphics-shape, możesz utworzyć przycisk, który po naciśnięciu zmienia kształt. Najpierw utwórz obiekt MorphPolygonShape, który rozszerza obiekt Shape, skalując i przekształcając go w odpowiednim zakresie. Zwróć uwagę na przekazywanie postępu, aby kształt mógł być animowany:

class MorphPolygonShape(
    private val morph: Morph,
    private val percentage: Float
) : Shape {

    private val matrix = Matrix()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f
        // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y.
        matrix.scale(size.width / 2f, size.height / 2f)
        matrix.translate(1f, 1f)

        val path = morph.toPath(progress = percentage).asComposePath()
        path.transform(matrix)
        return Outline.Generic(path)
    }
}

Aby użyć tego kształtu przejścia, utwórz 2 polygony: shapeAshapeB. Utwórz i zapamiętaj Morph. Następnie zastosuj przekształcenie do przycisku jako obrysu klipu, używając naciśnięcia przycisku interactionSource jako siły napędowej animacji:

val shapeA = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val shapeB = remember {
    RoundedPolygon.star(
        6,
        rounding = CornerRounding(0.1f)
    )
}
val morph = remember {
    Morph(shapeA, shapeB)
}
val interactionSource = remember {
    MutableInteractionSource()
}
val isPressed by interactionSource.collectIsPressedAsState()
val animatedProgress = animateFloatAsState(
    targetValue = if (isPressed) 1f else 0f,
    label = "progress",
    animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessMedium)
)
Box(
    modifier = Modifier
        .size(200.dp)
        .padding(8.dp)
        .clip(MorphPolygonShape(morph, animatedProgress.value))
        .background(Color(0xFF80DEEA))
        .size(200.dp)
        .clickable(interactionSource = interactionSource, indication = null) {
        }
) {
    Text("Hello", modifier = Modifier.align(Alignment.Center))
}

Gdy klikniesz pole, zobaczysz tę animację:

Przekształcenie zastosowane jako kliknięcie między 2 kształtami
Rysunek 12. Przekształcenie zastosowane jako kliknięcie między 2 kształtami.

animować nieskończone przekształcanie kształtu.

Aby utworzyć nieskończoną animację przekształcania kształtu, użyj rememberInfiniteTransition. Poniżej przedstawiamy przykład zdjęcia profilowego, które zmienia kształt (i obraca się) w nieskończoność. W tym podejściu wprowadzamy niewielkie dostosowanie do MorphPolygonShape pokazanego powyżej:

class CustomRotatingMorphShape(
    private val morph: Morph,
    private val percentage: Float,
    private val rotation: Float
) : Shape {

    private val matrix = Matrix()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f
        // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y.
        matrix.scale(size.width / 2f, size.height / 2f)
        matrix.translate(1f, 1f)
        matrix.rotateZ(rotation)

        val path = morph.toPath(progress = percentage).asComposePath()
        path.transform(matrix)

        return Outline.Generic(path)
    }
}

@Preview
@Composable
private fun RotatingScallopedProfilePic() {
    val shapeA = remember {
        RoundedPolygon(
            12,
            rounding = CornerRounding(0.2f)
        )
    }
    val shapeB = remember {
        RoundedPolygon.star(
            12,
            rounding = CornerRounding(0.2f)
        )
    }
    val morph = remember {
        Morph(shapeA, shapeB)
    }
    val infiniteTransition = rememberInfiniteTransition("infinite outline movement")
    val animatedProgress = infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            tween(2000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "animatedMorphProgress"
    )
    val animatedRotation = infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            tween(6000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "animatedMorphProgress"
    )
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Image(
            painter = painterResource(id = R.drawable.dog),
            contentDescription = "Dog",
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .clip(
                    CustomRotatingMorphShape(
                        morph,
                        animatedProgress.value,
                        animatedRotation.value
                    )
                )
                .size(200.dp)
        )
    }
}

Ten kod daje taki zabawny wynik:

Dłonie złożone w kształt serca
Rysunek 13. Zdjęcie profilowe przycięte przez obracający się kształt z ząbkami.

Wielokąty niestandardowe

Jeśli kształty utworzone z regularnych wielokątów nie spełniają Twoich potrzeb, możesz utworzyć bardziej niestandardowy kształt za pomocą listy wierzchołków. Możesz na przykład utworzyć kształt serca:

Dłonie złożone w kształt serca
Rysunek 14. Serce.

Poszczególne wierzchołki tego kształtu możesz określić za pomocą przeciążenia funkcji RoundedPolygon, która przyjmuje tablicę typu float z współrzędnymi x i y.

Aby podzielić serce na wielokąt, zwróć uwagę,że system współrzędnych biegunowych ułatwia określanie punktów niż system współrzędnych kartezjańskich (x, y), w którym zaczyna się po prawej stronie i przechodzi zgodnie z kierunkiem ruchu wskazówek zegara, a 270° znajduje się w pozycji godziny 12:

Dłonie złożone w kształt serca
Rysunek 15. Serce z współrzędnymi.

Kształt można teraz definiować łatwiej, podając kąt (𝜭) i promień od środka w każdym punkcie:

Dłonie złożone w kształt serca
Rysunek 16. Kształt serca z współrzędnymi bez zaokrągleń.

Wierzchołki można teraz utworzyć i przekazać funkcji RoundedPolygon:

val vertices = remember {
    val radius = 1f
    val radiusSides = 0.8f
    val innerRadius = .1f
    floatArrayOf(
        radialToCartesian(radiusSides, 0f.toRadians()).x,
        radialToCartesian(radiusSides, 0f.toRadians()).y,
        radialToCartesian(radius, 90f.toRadians()).x,
        radialToCartesian(radius, 90f.toRadians()).y,
        radialToCartesian(radiusSides, 180f.toRadians()).x,
        radialToCartesian(radiusSides, 180f.toRadians()).y,
        radialToCartesian(radius, 250f.toRadians()).x,
        radialToCartesian(radius, 250f.toRadians()).y,
        radialToCartesian(innerRadius, 270f.toRadians()).x,
        radialToCartesian(innerRadius, 270f.toRadians()).y,
        radialToCartesian(radius, 290f.toRadians()).x,
        radialToCartesian(radius, 290f.toRadians()).y,
    )
}

Wierzchołki należy przekształcić w współrzędne kartezjańskie za pomocą tej funkcji: radialToCartesian.

internal fun Float.toRadians() = this * PI.toFloat() / 180f

internal val PointZero = PointF(0f, 0f)
internal fun radialToCartesian(
    radius: Float,
    angleRadians: Float,
    center: PointF = PointZero
) = directionVectorPointF(angleRadians) * radius + center

internal fun directionVectorPointF(angleRadians: Float) =
    PointF(cos(angleRadians), sin(angleRadians))

Powyższy kod daje surowe wierzchołki serca, ale musisz zaokrąglić określone rogi, aby uzyskać wybrany kształt serca. Narożniki w miejscach 90°270° nie są zaokrąglone, ale inne narożniki już tak. Aby zastosować niestandardowe zaokrąglenie poszczególnych narożników, użyj parametru perVertexRounding:

val rounding = remember {
    val roundingNormal = 0.6f
    val roundingNone = 0f
    listOf(
        CornerRounding(roundingNormal),
        CornerRounding(roundingNone),
        CornerRounding(roundingNormal),
        CornerRounding(roundingNormal),
        CornerRounding(roundingNone),
        CornerRounding(roundingNormal),
    )
}

val polygon = remember(vertices, rounding) {
    RoundedPolygon(
        vertices = vertices,
        perVertexRounding = rounding
    )
}
Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygonPath = polygon.toPath().asComposePath()
            onDrawBehind {
                scale(size.width * 0.5f, size.width * 0.5f) {
                    translate(size.width * 0.5f, size.height * 0.5f) {
                        drawPath(roundedPolygonPath, color = Color(0xFFF15087))
                    }
                }
            }
        }
        .size(400.dp)
)

W efekcie pojawia się różowe serce:

Dłonie złożone w kształt serca
Rysunek 17. Wynik w kształcie serca.

Jeśli żadne z tych kształtów nie odpowiada Twoim potrzebom, użyj klasy Path, aby narysować własny kształt, lub załaduj plik ImageVector z dysku. Biblioteka graphics-shapes nie jest przeznaczona do tworzenia dowolnych kształtów, ale ma na celu uproszczenie tworzenia zaokrąglonych wielokątów i animacji przejściowych między nimi.

Dodatkowe materiały

Więcej informacji i przykładów znajdziesz w tych materiałach: