Kształty w oknie tworzenia wiadomości

Funkcja Utwórz umożliwia tworzenie kształtów złożonych z wielokątów. Możesz na przykład tworzyć takie kształty:

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

Aby utworzyć niestandardowy okrągły wielokąt w sekcji Utwórz, dodaj zależność graphics-shapes do app/build.gradle:

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

Ta biblioteka umożliwia tworzenie kształtów złożonych z wielokątów. Chociaż kształty wielokątne mają tylko proste krawędzie i ostre rogi, te kształty umożliwiają opcjonalne zaokrąglone narożniki. Ułatwia przekształcanie 2 kształtów. Przekształcanie obiektów w obrębie dowolnych kształtów jest trudne i bywa problematyczne podczas projektowania. Ta biblioteka ułatwia jednak pracę, przekształcając kształty o podobnych wielokątnych strukturach.

Tworzenie wielokątów

Ten fragment kodu tworzy podstawowy kształt wielokąta z 6 punktami pośrodku obszaru rysowania:

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 element RoundedPolygon, który zawiera geometrię reprezentującą żądany kształt. Aby narysować ten kształt w aplikacji Compose, musisz pobrać z niej obiekt Path, aby uzyskać kształt w postaci, która potrafi rysować.

Zaokrągla rogi wielokąta

Aby zaokrąglić rogi wielokąta, użyj parametru CornerRounding. Wymaga to 2 parametrów: radius i smoothing. Każdy zaokrąglony róg składa się z 1–3 krzywych sześciennych, których środek ma okrągły kształt, a krzywe dwustronne („koliczne”) przechodzą od krawędzi kształtu do krzywej środkowej.

Promień

radius to promień koła używanego do zaokrąglenia wierzchołku.

Na przykład taki trójkąt z zaokrąglonymi rogami tworzy się w następujący sposób:

Trójkąt z zaokrąglonymi narożnikami
Rysunek 3. Trójkąt z zaokrąglonymi narożnikami.
Promień zaokrąglenia r określa rozmiar zaokrąglonych narożników
Rysunek 4. Promień zaokrąglenia r określa rozmiar zaokrąglonych narożników.

Złagodzenie

Wygładzanie to czynnik określający, ile czasu potrzeba na przejście od zaokrąglonego narożnika do krawędzi. Współczynnik wygładzania równy 0 (bez wygładzania, wartość domyślna CornerRounding) powoduje całkowite zaokrąglenie rogów. Jeśli współczynnik wygładzania nie jest zerowy (do maksymalnej wartości 1,0), narożnik jest zaokrąglany przez 3 osobne krzywe.

Współczynnik wygładzania o wartości 0 (bez wygładzania) tworzy pojedynczą krzywą sześcienna, która podąża po okręgu wokół narożnika o określonym promieniu zaokrąglenia, jak we wcześniejszym przykładzie
Rysunek 5. Współczynnik wygładzania równy 0 (bez wygładzania) tworzy pojedynczą krzywą sześcienna, która biegnie wzdłuż narożnika o określonym promieniu zaokrąglenia, jak we wcześniejszym przykładzie.
W wyniku niezerowego współczynnika wygładzania dookoła wierzchołku są tworzone 3 krzywe sześcienne: wewnętrzną krzywą okrągłą (jak wcześniej) oraz dwie krzywe towarzyszące, które przechodzą między krzywą wewnętrzną a krawędziami wielokąta.
Rysunek 6. W wyniku działania niezerowego współczynnika wygładzania dookoła wierzchołku są tworzone trzy krzywe sześcienne: wewnętrzną krzywą okrągłą (jak wcześniej) oraz dwie krzywe towarzyszące, które przechodzą między krzywą wewnętrzną a krawędziami wielokąta.

Na przykład w tym fragmencie kodu widać subtelną różnicę w wygładzeniu wygładzania do wartości 0 i 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 pozycja

Domyślnie tworzony jest kształt o promieniu 1 dookoła środka (0, 0). Ten promień reprezentuje odległość między środkowymi a zewnętrznymi wierzchołkami wielokąta, na którym oparty jest kształt. Pamiętaj, że zaokrąglanie rogów powoduje zmniejszenie kształtu, ponieważ zaokrąglone narożniki są bliżej środka niż wierzchołki zaokrąglone. Aby zmienić rozmiar wielokąta, dostosuj wartość radius. Aby dostosować położenie, zmień centerX lub centerY wielokąta. Możesz też przekształcić obiekt, aby zmienić jego rozmiar, położenie i obrót, korzystając ze standardowych funkcji przekształcania 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ć 2 kształty, utwórz dwa obiekty RoundedPolygons i Morph, które mają te kształty. Aby obliczyć kształt między kształtami początkowymi i końcowymi, podaj wartość progress z zakresu od 0 do 1, aby określić jej kształt między kształtami początkowymi (0) i końcowymi (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 przykładzie powyżej postęp jest dokładnie w połowie drogi między 2 kształtami (trójkątem zaokrąglonym i kwadratem), co daje taki wynik:

50% drogi między zaokrąglonym trójkątem a kwadratem
Rysunek 8. 50% długości zaokrąglonego trójkąta i kwadratu.

W większości przypadków przekształcanie odbywa się w ramach animacji, a nie tylko jako renderowania statycznego. Do animowania tych 2 elementów możesz używać standardowych interfejsów API animacji w tworzeniu, które pozwalają zmieniać wartość postępu w czasie. Możesz na przykład animować przejście między tymi dwoma kształtami w nieskończoność w następujący sposób:

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 przekształcanie między kwadratem a zaokrąglonym trójkątem
Rysunek 9. nieskończone przekształcanie między kwadratem a zaokrąglonym trójkątem.

Użyj wielokąta jako klipu

Do zmiany sposobu renderowania funkcji kompozycyjnej i lepszego wykorzystania cieni rysujących się wokół obszaru przycinania często używa się modyfikatora clip w interfejsie Compose:

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, tak jak w tym przykładzie:

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)
    )
}

Efektem jest to, że:

Sześciokąt z napisem „hello compose” pośrodku.
Rysunek 10. Sześciokąt z umieszczonym pośrodku tekstem „Hello Compose”.

Ta cecha może nie wyglądać podobnie do wcześniejszej renderowania, ale umożliwia korzystanie z innych funkcji tworzenia wiadomości. Tej techniki można np. używać do przycinania obrazu i nakładania cienia wokół przycię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 kształcie sześciokąta z cieniem wokół krawędzi
Rysunek 11. Niestandardowy kształt został zastosowany jako klip.

Przycisk przesuwania po kliknięciu

Możesz użyć biblioteki graphics-shape, aby utworzyć przycisk, który zmienia się po naciśnięciu. Najpierw utwórz MorphPolygonShape, który rozszerza zakres Shape, skalując i tłumacząc go, aby odpowiednio go dopasować. Zwróć uwagę na przebieg 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, utwórz dwa wielokąty, shapeA i shapeB. Utwórz i zapamiętaj Morph. Następnie zastosuj przekształcenie przycisku jako kontur klipu. interactionSource przy naciśnięciu jest siłą napędową 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))
}

Powoduje to wyświetlenie takiej animacji po kliknięciu pola:

Morph zastosowany jako kliknięcie między dwoma kształtami
Rysunek 12. Kształt przekształcony w postaci kliknięcia łączącego dwa kształty.

Animuj zniekształcenie kształtu w nieskończoność

Aby animować kształt w nieskończoność, użyj funkcji rememberInfiniteTransition. Poniżej znajdziesz przykład zdjęcia profilowego, które zmienia kształt (i obraca się) w nieskończoność. Ta metoda wprowadza niewielką zmianę w MorphPolygonShape pokazanych 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ę, pochylony kształt.

Niestandardowe wielokąty

Jeśli kształty utworzone z wielokątów foremnych nie pokrywają się z Twoim przypadkiem użycia, możesz utworzyć bardziej niestandardowy kształt z listą wierzchołków. Możesz np. utworzyć taki kształt serca:

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

Możesz określić poszczególne wierzchołki tego kształtu za pomocą przeciążenia RoundedPolygon, które przyjmuje tablicę zmiennoprzecinkową ze współrzędnych x i y.

Aby rozbić wielokąt serca, zwróć uwagę, że zastosowanie współrzędnych biegunowych do określania punktów jest łatwiejsze niż użycie kartezjańskiego (x i y) układu współrzędnych, w którym rozpoczyna się po prawej stronie i działa w prawo, a 270° jest w pozycji dwunastej:

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

Kształt można teraz łatwiej zdefiniować, określając kąt (⌘) i promień w każdym punkcie:

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

Teraz można tworzyć wierzchołki i przekazywać je do 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,
    )
}

Wierzchory należy przekształcić na 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))

Poprzedni kod pokazuje surowe wierzchołki serca, ale musisz zaokrąglić konkretne narożniki, aby uzyskać wybrany kształt serca. Narożniki 90° i 270° nie mają zaokrąglonych rogów, natomiast pozostałe narożniki już tak. Aby niestandardowe zaokrąglić poszczególne narożniki, 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)
)

Otrzymasz różowe serce:

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

Jeśli poprzednie kształty nie pokrywają się z Twoim przypadkiem użycia, rozważ użycie klasy Path do narysowania kształtu niestandardowego lub wczytanie pliku ImageVector z dysku. Biblioteka graphics-shapes nie służy do obsługi dowolnych kształtów – ma na celu uproszczenie tworzenia zaokrąglonych wielokątów i przekształcanie między nimi animacji.

Dodatkowe materiały

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