Kształty w oknie tworzenia wiadomości

Za pomocą Compose możesz tworzyć kształty składające się z wielokątów. Możesz na przykład tworzyć takie kształty:

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

Aby utworzyć niestandardowy zaokrąglony wielokąt w Compose, 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 składających się z wielokątów. Kształty wielokątne mają tylko proste krawędzie i ostre rogi, natomiast te kształty mogą mieć zaokrąglone rogi. Ułatwia to przekształcanie jednego kształtu w drugi. Morfing między dowolnymi kształtami jest trudny i często stanowi problem na etapie projektowania. Ta biblioteka upraszcza jednak ten proces, przekształcając kształty o podobnych strukturach wielokątów.

Tworzenie wielokątów

Poniższy 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 poś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 pobrać z niego obiekt Path, aby uzyskać kształt w formie, którą Compose potrafi narysować.

Zaokrąglanie rogów wielokąta

Aby zaokrąglić rogi wielokąta, użyj parametru CornerRounding. Wymaga on 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 kształt łuku kołowego, a 2 krzywe boczne („flankujące”) przechodzą od krawędzi kształtu do krzywej środkowej.

Promień

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

Na przykład ten trójkąt z zaokrąglonymi rogami jest tworzony 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.

Wygładzenie

Wygładzanie to współczynnik, który określa, ile czasu zajmuje przejście od okrągłego zaokrąglenia rogu do krawędzi. Współczynnik wygładzania 0 (niewygładzony, wartość domyślna dla CornerRounding) powoduje, że rogi są zaokrąglone w kształt koła. Współczynnik wygładzania o wartości innej niż zero (maksymalnie 1,0) powoduje, że róg jest zaokrąglany za pomocą 3 osobnych krzywych.

Współczynnik wygładzania 0 (brak wygładzania) daje pojedynczą krzywą sześcienną, która biegnie po okręgu wokół rogu z określonym promieniem zaokrąglenia, jak we wcześniejszym przykładzie.
Rysunek 5. Współczynnik wygładzania 0 (niewygładzony) daje pojedynczą krzywą sześcienną, która biegnie po okręgu wokół rogu z określonym promieniem zaokrąglenia, jak we wcześniejszym przykładzie.
Współczynnik wygładzania o wartości innej niż zero 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.
Rysunek 6. Współczynnik wygładzania o wartości innej niż zero 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 ilustruje subtelną różnicę między ustawieniem wygładzania na 0 i na 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ą różnicę w parametrze wygładzania.

Rozmiar i pozycja

Domyślnie kształt jest tworzony z promieniem 1 wokół środka (0, 0). Promień ten reprezentuje odległość między środkiem a zewnętrznymi wierzchołkami wielokąta, na którym opiera się 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 obrót, używając standardowych funkcji przekształcania DrawScope, takich jak DrawScope#translate().

Przekształcanie kształtów

Obiekt Morph to nowy kształt reprezentujący animację między 2 kształtami wielokątnymi. Aby przekształcić jeden kształt w drugi, utwórz 2 obiekty RoundedPolygons i obiekt Morph, który przyjmuje te 2 kształty. Aby obliczyć kształt między kształtem początkowym i końcowym, podaj wartość progress z zakresu od 0 do 1, która określi jego formę między kształtem początkowym (0) i 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 przykładzie powyżej postęp jest dokładnie w połowie drogi między 2 kształtami (zaokrąglonym trójkątem i kwadratem), co daje taki wynik:

50% odległości między zaokrąglonym trójkątem a kwadratem
Rysunek 8. 50% odległości 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 renderowania statycznego. Aby animować przejście między tymi stanami, możesz użyć standardowych interfejsów Animation API w Compose, które pozwalają zmieniać wartość postępu w czasie. Możesz na przykład bez końca animować przekształcanie tych 2 kształtów 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()
)

Nieskończone przekształcanie kwadratu w zaokrąglony trójkąt
Rysunek 9. Kształt płynnie zmienia się z kwadratu w zaokrąglony trójkąt.

Użyj wielokąta jako maski przycinania

W Compose często używa się modyfikatora clip, aby zmienić sposób renderowania elementu kompozycyjnego i wykorzystać cienie, które są rysowane 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 maski przycinania, 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)
    )
}

Efekty:

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

Może to nie wyglądać inaczej niż wcześniej, ale pozwala na korzystanie z innych funkcji w Compose. Można na przykład użyć tej techniki 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 wokół krawędzi
Rysunek 11. Niestandardowy kształt został zastosowany jako klip.

Przekształć przycisk 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 przekształcając, aby odpowiednio go dopasować. Zwróć uwagę na przekazywanie postępu, 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 przekształcenia, utwórz 2 wielokąty: shapeA i shapeB. Utwórz i zapamiętaj Morph. Następnie zastosuj przekształcenie do przycisku jako kontur 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 wyświetli się taka animacja:

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

Animowanie zmiany kształtu w nieskończoność

Aby animować kształt przekształcenia w nieskończoność, użyj rememberInfiniteTransition. Poniżej znajduje się przykład zdjęcia profilowego, które w nieskończoność zmienia kształt (i obraca się). To podejście wymaga niewielkiej zmiany w MorphPolygonShape pokazanym 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:

Kształt serca
Rysunek 13. Zdjęcie profilowe przycięte przez obracający się kształt z falistymi krawędziami.

Wielokąty niestandardowe

Jeśli kształty utworzone z wielokątów foremnych nie odpowiadają Twoim potrzebom, możesz utworzyć bardziej niestandardowy kształt, korzystając z listy wierzchołków. Możesz na przykład utworzyć kształt serca:

Kształt serca
Rysunek 14. Kształt serca.

Możesz określić poszczególne wierzchołki tego kształtu, używając przeciążenia RoundedPolygon, które przyjmuje tablicę zmiennoprzecinkową współrzędnych x, 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), gdzie 0° zaczyna się po prawej stronie i postępuje zgodnie z ruchem wskazówek zegara, a 270° znajduje się na pozycji godziny 12:

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ń od środka w każdym punkcie:

Kształt serca
Rysunek 16. Kształt serca ze współrzędnymi, bez zaokrąglania.

Teraz można utworzyć wierzchołki i przekazać 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,
    )
}

Wierzchołki 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))

Powyższy kod zawiera nieprzetworzone wierzchołki serca, ale musisz zaokrąglić określone rogi, aby uzyskać wybrany kształt serca. Rogi w punktach 90° i 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 pojawi się różowe serce:

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 z dowolnymi kształtami, ale ma upraszczać tworzenie 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: