Kształty w oknie tworzenia wiadomości

W trybie Kompozycja możesz tworzyć kształty składające się z wielokątó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żna utworzyć za pomocą biblioteki graphics-shapes

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

implementation "androidx.graphics:graphics-shapes:1.0.1"

Ta biblioteka umożliwia tworzenie kształtów z wielokątów. Kształty wielokątne mają tylko proste krawędzie i ostre rogi, ale mogą mieć opcjonalnie zaokrąglone rogi. Ułatwia to płynne przejście między dwoma różnymi kształtami. Przekształcanie między dowolnymi kształtami jest trudne i zwykle stanowi problem na etapie projektowania. Ta biblioteka ułatwia jednak to zadanie, ponieważ umożliwia płynne przechodzenie między tymi kształtami o podobnych strukturach wielokątnych.

Tworzenie wielokątów

Ten fragment kodu tworzy podstawowy wielokąt z 6 punktami na ś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 obiekt RoundedPolygon, który zawiera geometrię reprezentującą żądany kształt. Aby narysować ten kształt w aplikacji Compose, musisz uzyskać z niego obiekt Path, aby przekształcić go w formę, którą Compose potrafi narysować.

Zaokrąglanie narożników wielokąta

Aby zaokrąglić rogi 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 kołowego, a dwie krzywe boczne („flankujące”) przechodzą od krawędzi kształtu do krzywej środkowej.

Promień

radius to promień okręgu używanego do zaokrąglania wierzchołka.

Na przykład ten trójkąt z zaokrąglonymi rogami powstaje w ten sposób:

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

Złagodzenie

Wygładzanie to czynnik określający, jak długo trwa przejście od zaokrąglonej części rogu do krawędzi. Współczynnik wygładzania 0 (niewygładzony, domyślna wartość CornerRounding) powoduje zaokrąglenie narożników w postaci czystego okręgu. Niezerowy współczynnik wygładzania (maksymalnie 1,0) powoduje, że narożnik jest zaokrąglany przez 3 osobne krzywe.

Współczynnik wygładzania 0 (bez wygładzania) tworzy pojedynczą krzywą sześcienną, która
podąża za okręgiem wokół narożnika o określonym promieniu zaokrąglenia, jak w poprzednim przykładzie.
Rysunek 5. Współczynnik wygładzania 0 (bez wygładzania) tworzy pojedynczą krzywą sześcienną, która przebiega po okręgu wokół narożnika o określonym promieniu zaokrąglenia, jak w poprzednim przykładzie.
Niezerowy współczynnik wygładzania tworzy 3 krzywe sześcienne, które zaokrąglają wierzchołek: wewnętrzną krzywą okrągłą (jak wcześniej) oraz 2 krzywe boczne, które przechodzą między krzywą wewnętrzną a krawędziami wielokąta.
Rysunek 6. Niezerowy współczynnik wygładzania tworzy 3 krzywe sześcienne, które zaokrąglają wierzchołek: wewnętrzną krzywą kołową (jak wcześniej) oraz 2 krzywe boczne, 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ętrznymi wierzchołkami wielokąta, na którym oparty jest kształt. Pamiętaj, że zaokrąglenie rogów powoduje zmniejszenie kształtu, ponieważ zaokrąglone rogi będą bliżej środka niż zaokrąglane wierzchołki. Aby zmienić rozmiar wielokąta, dostosuj wartość radius. Aby dostosować pozycję, zmień centerX lub centerY wielokąta. Możesz też przekształcić obiekt, aby zmienić jego rozmiar, położenie i rotację, korzystając ze standardowych funkcji przekształcania, takich jak DrawScopeDrawScope#translate().

Przekształcanie kształtów

Obiekt Morph to nowy kształt reprezentujący animację między dwoma wielokątnymi kształtami. Aby przekształcić jeden kształt w drugi, utwórz 2 obiekty RoundedPolygons i 1 obiekt Morph, który przyjmuje te 2 kształty. 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 powyższym przykładzie postęp jest dokładnie w połowie drogi między dwoma kształtami (zaokrąglonym trójkątem i kwadratem), co daje następujący wynik:

50% odległości między zaokrąglonym trójkątem a kwadratem
Rysunek 8. W 50% między zaokrąglonym trójkątem a kwadratem.

W większości przypadków przekształcanie jest częścią animacji, a nie tylko statycznego renderowania. Aby animować przejście między tymi 2 wartościami, możesz użyć standardowych interfejsów API animacji w Compose, aby zmieniać wartość postępu w czasie. Możesz na przykład nieskończenie animować przekształcanie między tymi dwoma kształtami w ten 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()
)

Płynne przechodzenie między kwadratem a zaokrąglonym trójkątem
Rysunek 9. Płynne przechodzenie między kwadratem a zaokrąglonym trójkątem.

Używanie wielokąta jako klipu

W Compose często używa się modyfikatora clip do zmiany sposobu renderowania komponentu kompozycyjnego i korzystania 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)
    }
}

Wielokąt możesz następnie wykorzystać jako klip, 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)
    )
}

Spowoduje to:

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

Może to nie wyglądać inaczej niż wcześniej, ale pozwala na korzystanie z innych funkcji w Compose. Na przykład tę technikę można wykorzystać do przycięcia obrazu i zastosowania 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 sześciokącie z cieniem na krawędziach
Rysunek 11. Kształt niestandardowy zastosowany jako klip.

Przycisk przekształcenia po kliknięciu

Za pomocą biblioteki graphics-shape możesz utworzyć przycisk, który po naciśnięciu zmienia kształt. Najpierw utwórz element MorphPolygonShape, który rozszerza element Shape, skalując go i przesuwając, aby odpowiednio dopasować go do ekranu. Zwróć uwagę na przekazywanie wartości zmiennej progress, aby można było animować kształt:

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 2 wielokąty: shapeAshapeB. Utwórz i zapamiętaj Morph. Następnie zastosuj przekształcenie do przycisku jako obramowania klipu, używając interactionSource po naciśnięciu 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))
}

Po kliknięciu pola pojawi się taka animacja:

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

Animowanie przekształcania kształtu w nieskończoność

Aby animować kształt przekształcenia w nieskończoność, użyj rememberInfiniteTransition. Poniżej znajdziesz przykład zdjęcia profilowego, które zmienia kształt (i obraca się) w nieskończoność. W tym podejściu stosuje się niewielką korektę w stosunku do wartości MorphPolygonShape podanej 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 wycięciami.

Wielokąty niestandardowe

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

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

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

Aby podzielić wielokąt serca, zauważ,że układ współrzędnych biegunowych do określania punktów ułatwia to zadanie w porównaniu z układem współrzędnych kartezjańskich (x, y), w którym zaczyna się po prawej stronie i przebiega zgodnie z ruchem wskazówek zegara, a 270° znajduje się na pozycji godziny 12:

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

Kształt można teraz łatwiej zdefiniować, określają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 ze współrzędnymi bez zaokrąglania.

Wierzchołki można teraz tworzyć i przekazywać 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,
    )
}

Wierzchołki należy przetłumaczyć 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))

Powyższy kod zawiera surowe wierzchołki serca, ale aby uzyskać wybrany kształt, musisz zaokrąglić określone rogi. Rogi w punktach 90°270° nie są zaokrąglone, ale pozostałe rogi są. Aby uzyskać niestandardowe zaokrąglenie poszczególnych rogó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 otrzymasz różowe serce:

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

Jeśli powyższe kształty nie odpowiadają Twoim potrzebom, możesz użyć klasy Path, aby narysować niestandardowy kształt, lub wczytać plik ImageVector z dysku. Biblioteka graphics-shapes nie jest przeznaczona do używania w przypadku dowolnych kształtów, ale ma na celu uproszczenie tworzenia zaokrąglonych wielokątów i animacji przekształcania między nimi.

Dodatkowe materiały

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