1 /*
<lambda>null2 * Copyright 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.graphics.shapes.testcompose
18
19 import android.content.Intent
20 import android.os.Bundle
21 import androidx.activity.compose.setContent
22 import androidx.compose.animation.animateContentSize
23 import androidx.compose.animation.core.animateFloatAsState
24 import androidx.compose.animation.core.tween
25 import androidx.compose.foundation.layout.Arrangement
26 import androidx.compose.foundation.layout.Column
27 import androidx.compose.foundation.layout.Row
28 import androidx.compose.foundation.layout.Spacer
29 import androidx.compose.foundation.layout.fillMaxHeight
30 import androidx.compose.foundation.layout.fillMaxWidth
31 import androidx.compose.foundation.layout.height
32 import androidx.compose.foundation.layout.padding
33 import androidx.compose.foundation.layout.size
34 import androidx.compose.foundation.layout.width
35 import androidx.compose.foundation.rememberScrollState
36 import androidx.compose.foundation.selection.selectableGroup
37 import androidx.compose.foundation.shape.RoundedCornerShape
38 import androidx.compose.foundation.verticalScroll
39 import androidx.compose.material.icons.Icons
40 import androidx.compose.material.icons.automirrored.filled.ArrowBack
41 import androidx.compose.material.icons.filled.Add
42 import androidx.compose.material.icons.filled.Check
43 import androidx.compose.material.icons.filled.Clear
44 import androidx.compose.material.icons.filled.Edit
45 import androidx.compose.material.icons.filled.Info
46 import androidx.compose.material.icons.filled.KeyboardArrowDown
47 import androidx.compose.material.icons.filled.Share
48 import androidx.compose.material.icons.filled.Warning
49 import androidx.compose.material3.AlertDialog
50 import androidx.compose.material3.Button
51 import androidx.compose.material3.Card
52 import androidx.compose.material3.ExperimentalMaterial3Api
53 import androidx.compose.material3.HorizontalDivider
54 import androidx.compose.material3.Icon
55 import androidx.compose.material3.IconButton
56 import androidx.compose.material3.MaterialTheme
57 import androidx.compose.material3.OutlinedButton
58 import androidx.compose.material3.RadioButton
59 import androidx.compose.material3.Scaffold
60 import androidx.compose.material3.SegmentedButton
61 import androidx.compose.material3.SegmentedButtonDefaults
62 import androidx.compose.material3.SingleChoiceSegmentedButtonRow
63 import androidx.compose.material3.Text
64 import androidx.compose.material3.TextButton
65 import androidx.compose.material3.TextField
66 import androidx.compose.material3.TopAppBar
67 import androidx.compose.runtime.Composable
68 import androidx.compose.runtime.MutableState
69 import androidx.compose.runtime.getValue
70 import androidx.compose.runtime.mutableIntStateOf
71 import androidx.compose.runtime.mutableStateOf
72 import androidx.compose.runtime.remember
73 import androidx.compose.runtime.setValue
74 import androidx.compose.runtime.snapshots.SnapshotStateList
75 import androidx.compose.runtime.toMutableStateList
76 import androidx.compose.ui.Alignment
77 import androidx.compose.ui.Modifier
78 import androidx.compose.ui.draw.rotate
79 import androidx.compose.ui.text.AnnotatedString
80 import androidx.compose.ui.text.ParagraphStyle
81 import androidx.compose.ui.text.SpanStyle
82 import androidx.compose.ui.text.buildAnnotatedString
83 import androidx.compose.ui.text.font.FontWeight
84 import androidx.compose.ui.text.input.TextFieldValue
85 import androidx.compose.ui.text.style.TextOverflow
86 import androidx.compose.ui.text.withStyle
87 import androidx.compose.ui.unit.dp
88 import androidx.fragment.app.FragmentActivity
89 import androidx.graphics.shapes.Feature
90 import androidx.graphics.shapes.FeatureSerializer
91 import androidx.graphics.shapes.RoundedPolygon
92 import androidx.graphics.shapes.SvgPathParser
93
94 class ShapeEditor : FragmentActivity() {
95 override fun onCreate(savedInstanceState: Bundle?) {
96 super.onCreate(savedInstanceState)
97 setContent(parent = null) { MaterialTheme { PolygonEditor(this) } }
98 }
99 }
100
101 @Composable
PolygonEditornull102 private fun PolygonEditor(activity: FragmentActivity) {
103 val shapeParams = remember { materialShapes().map { mutableStateOf(it) }.toMutableStateList() }
104
105 var selectedStartShape by remember { mutableIntStateOf(5) }
106 var selectedEndShape by remember { mutableIntStateOf(16) }
107 var selectedIndex by remember { mutableIntStateOf(0) }
108 val selectedShape = if (selectedIndex == 0) selectedStartShape else selectedEndShape
109 val currentScreenType: MutableState<ScreenTypes> = remember { mutableStateOf(ScreenTypes.HOME) }
110
111 val changeSelected = { shapeIndex: Int ->
112 if (selectedIndex == 0) selectedStartShape = shapeIndex else selectedEndShape = shapeIndex
113 }
114
115 val screens: Map<ScreenTypes, @Composable () -> Unit> =
116 mapOf(
117 ScreenTypes.HOME to
118 {
119 HomeScreen(
120 shapeParams,
121 selectedStartShape,
122 selectedEndShape,
123 selectedShape,
124 selectedIndex,
125 onSelectedSwitch = { index -> selectedIndex = index },
126 onShapeSwitch = changeSelected,
127 onEditClick = { currentScreenType.value = ScreenTypes.EDIT },
128 onAboutClick = { currentScreenType.value = ScreenTypes.ABOUT },
129 onExportClick = {
130 exportShape(activity, shapeParams[selectedShape].value.serialized())
131 },
132 onAddShapeClick = {
133 shapeParams.add(mutableStateOf(ShapeParameters("Custom")))
134 changeSelected(shapeParams.lastIndex)
135 // Give this shape a chance to be visualized, as the visualization
136 // will initialize/sync properties like the feature overlay and we do
137 // not
138 // want a prompt for a 'save changes?' when users go back immediately
139 shapeParams.last().value.genShape()
140 currentScreenType.value = ScreenTypes.EDIT
141 },
142 onImportShapeClick = { parseFunction: () -> List<Feature> ->
143 val features: List<Feature> = parseFunction()
144 shapeParams.add(
145 mutableStateOf(
146 CustomShapeParameters("Custom") {
147 RoundedPolygon(features).normalized()
148 }
149 )
150 )
151 changeSelected(shapeParams.lastIndex)
152 }
153 )
154 },
155 ScreenTypes.EDIT to
156 {
157 EditScreen(
158 shapeParams[selectedShape].value,
159 onBackClick = { currentScreenType.value = ScreenTypes.HOME },
160 onSave = { newParams: ShapeParameters ->
161 shapeParams[selectedShape].value = newParams
162 currentScreenType.value = ScreenTypes.HOME
163 }
164 )
165 },
166 ScreenTypes.ABOUT to
167 {
168 AboutScreen(onBackClick = { currentScreenType.value = ScreenTypes.HOME })
169 }
170 )
171
172 screens[currentScreenType.value]?.let { it() }
173 }
174
175 @Composable
EditScreennull176 private fun EditScreen(
177 parameters: ShapeParameters,
178 onBackClick: () -> Unit,
179 onSave: (ShapeParameters) -> Unit
180 ) {
181 var showSaveMessage by remember { mutableStateOf(false) }
182 val copyToEdit = remember { parameters.copy() }
183 var selectedViewModeIndex by remember { mutableIntStateOf(0) }
184
185 if (showSaveMessage) {
186 SaveConfirmationDialog(
187 onDismissRequest = {
188 showSaveMessage = false
189 onBackClick()
190 },
191 onConfirmation = { onSave(copyToEdit) }
192 )
193 }
194
195 Scaffold(
196 topBar = {
197 EditScreenHeader(
198 selectedViewModeIndex,
199 onBackClick = {
200 if (!copyToEdit.equals(parameters)) showSaveMessage = true else onBackClick()
201 },
202 onModeSwitch = { index -> selectedViewModeIndex = index }
203 )
204 },
205 bottomBar = { EditScreenFooter(onCancel = onBackClick, onSave = { onSave(copyToEdit) }) }
206 ) { innerPadding ->
207 Column(
208 modifier = Modifier.padding(innerPadding),
209 horizontalAlignment = Alignment.CenterHorizontally,
210 verticalArrangement = Arrangement.spacedBy(3.dp),
211 ) {
212 if (selectedViewModeIndex == 0) {
213 ParametricEditor(copyToEdit)
214 } else {
215 FeatureEditor(copyToEdit)
216 }
217 }
218 }
219 }
220
221 @OptIn(ExperimentalMaterial3Api::class)
222 @Composable
EditScreenHeadernull223 private fun EditScreenHeader(
224 selectedIndex: Int,
225 onBackClick: () -> Unit,
226 onModeSwitch: (Int) -> Unit
227 ) {
228 val options = listOf("Parametric", "Features")
229
230 TopAppBar(
231 title = { Text("Shape Edit") },
232 actions = {
233 SingleChoiceSegmentedButtonRow {
234 options.forEachIndexed { index, label ->
235 SegmentedButton(
236 shape =
237 SegmentedButtonDefaults.itemShape(index = index, count = options.size),
238 onClick = { onModeSwitch(index) },
239 selected = index == selectedIndex,
240 icon = {}
241 ) {
242 Text(label)
243 }
244 }
245 }
246 },
247 navigationIcon = {
248 IconButton(onClick = onBackClick) {
249 Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Go back to Home")
250 }
251 }
252 )
253 }
254
255 @Composable
EditScreenFooternull256 private fun EditScreenFooter(onCancel: () -> Unit, onSave: () -> Unit) {
257 Row(
258 Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
259 horizontalArrangement = Arrangement.Center
260 ) {
261 OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) {
262 Icon(
263 Icons.Default.Clear,
264 contentDescription = "Cancel",
265 modifier = Modifier.padding(horizontal = 6.dp)
266 )
267 Text("Cancel")
268 }
269 Spacer(Modifier.width(12.dp))
270 Button(onClick = onSave, modifier = Modifier.weight(1f)) {
271 Icon(
272 Icons.Default.Check,
273 contentDescription = "Save",
274 modifier = Modifier.padding(horizontal = 6.dp)
275 )
276 Text("Save")
277 }
278 }
279 }
280
281 @Composable
HomeScreennull282 private fun HomeScreen(
283 shapeParams: SnapshotStateList<MutableState<ShapeParameters>>,
284 selectedStartShape: Int,
285 selectedEndShape: Int,
286 selectedShape: Int,
287 selectedIndex: Int,
288 onSelectedSwitch: (Int) -> Unit,
289 onShapeSwitch: (Int) -> Unit,
290 onEditClick: () -> Unit,
291 onAboutClick: () -> Unit,
292 onExportClick: () -> Unit,
293 onAddShapeClick: () -> Unit,
294 onImportShapeClick: (() -> List<Feature>) -> Unit
295 ) {
296 val shapes =
297 remember(shapeParams.size) { shapeParams.map { sp -> sp.value.genShape().normalized() } }
298
299 var showImportMessage by remember { mutableStateOf(false) }
300
301 Scaffold(
302 topBar = {
303 HomeScreenHeader(
304 selectedIndex,
305 shapes[selectedStartShape],
306 shapes[selectedEndShape],
307 onSelectedSwitch
308 )
309 },
310 bottomBar = { HomeScreenFooter(onAboutClick, onExportClick) }
311 ) { innerPadding ->
312 Column(
313 modifier = Modifier.padding(innerPadding),
314 horizontalAlignment = Alignment.CenterHorizontally,
315 verticalArrangement = Arrangement.spacedBy(3.dp),
316 ) {
317 if (showImportMessage) {
318 NewShapeDialog(
319 { showImportMessage = false },
320 {
321 onAddShapeClick()
322 showImportMessage = false
323 },
324 {
325 onImportShapeClick(it)
326 showImportMessage = false
327 }
328 )
329 }
330
331 ShapesGallery(
332 shapes,
333 selectedShape,
334 if (selectedIndex == 0) selectedEndShape else selectedStartShape,
335 Modifier.fillMaxHeight(0.35f).verticalScroll(rememberScrollState()),
336 onShapeSwitch
337 )
338
339 EditButtonRow(onEditClick) { showImportMessage = true }
340
341 Spacer(Modifier.height(12.dp))
342
343 HorizontalDivider(thickness = 2.dp)
344
345 AnimatedMorphView(shapes, selectedStartShape, selectedEndShape)
346 }
347 }
348 }
349
350 @OptIn(ExperimentalMaterial3Api::class)
351 @Composable
HomeScreenHeadernull352 private fun HomeScreenHeader(
353 selectedIndex: Int,
354 selectedStartShape: RoundedPolygon,
355 selectedEndShape: RoundedPolygon,
356 onSwitch: (Int) -> Unit
357 ) {
358 val options =
359 listOf(
360 "From",
361 "To",
362 )
363
364 TopAppBar(
365 title = { Text("Shape Selection") },
366 actions = {
367 SingleChoiceSegmentedButtonRow {
368 options.forEachIndexed { index, label ->
369 SegmentedButton(
370 shape =
371 SegmentedButtonDefaults.itemShape(index = index, count = options.size),
372 onClick = { onSwitch(index) },
373 selected = index == selectedIndex,
374 icon = {},
375 label = {
376 Row(horizontalArrangement = Arrangement.SpaceEvenly) {
377 Text(label)
378 Spacer(Modifier.size(10.dp))
379 if (index == 0) PolygonView(selectedStartShape)
380 else PolygonView(selectedEndShape)
381 }
382 }
383 )
384 }
385 }
386 }
387 )
388 }
389
390 @Composable
HomeScreenFooternull391 private fun HomeScreenFooter(onAboutClick: () -> Unit, onExportClick: () -> Unit) {
392 Row(
393 Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
394 horizontalArrangement = Arrangement.Center
395 ) {
396 OutlinedButton(onClick = onAboutClick, modifier = Modifier.weight(1f)) {
397 Icon(
398 Icons.Default.Info,
399 contentDescription = "Help",
400 modifier = Modifier.padding(horizontal = 6.dp)
401 )
402 Text("Usage FAQ")
403 }
404 Spacer(Modifier.width(12.dp))
405 Button(onClick = onExportClick, modifier = Modifier.weight(1f)) {
406 Icon(
407 Icons.Default.Share,
408 contentDescription = "Export",
409 modifier = Modifier.padding(horizontal = 6.dp)
410 )
411 Text("Export")
412 }
413 }
414 }
415
416 @OptIn(ExperimentalMaterial3Api::class)
417 @Composable
AboutScreennull418 fun AboutScreen(onBackClick: () -> Unit) {
419 val padding = 10.dp
420 Scaffold(
421 topBar = {
422 TopAppBar(
423 title = { Text("Help") },
424 navigationIcon = {
425 IconButton(onClick = onBackClick) {
426 Icon(
427 Icons.AutoMirrored.Default.ArrowBack,
428 contentDescription = "Go back to Home"
429 )
430 }
431 }
432 )
433 },
434 ) { innerPadding ->
435 Column(
436 modifier = Modifier.padding(30.dp).verticalScroll(rememberScrollState()),
437 horizontalAlignment = Alignment.CenterHorizontally,
438 verticalArrangement = Arrangement.spacedBy(3.dp),
439 ) {
440 Spacer(Modifier.height(innerPadding.calculateTopPadding()))
441
442 ExpandableQuestionCard(
443 "What are Shape Features and why should I care about them?",
444 AnnotatedString(
445 "Features create special target points on a shape that the morph algorithm uses to guide the shape's transformation. The algorithm will match Features of the same type (except 'None') between shapes. You can see and edit these points by going to Edit > Features. By understanding and manipulating Features, you can control how your shapes change during a morph."
446 ),
447 Modifier.padding(vertical = padding)
448 )
449
450 ExpandableQuestionCard(
451 "How can I make my Morph symmetrical?",
452 SYMMETRIC_SHAPE_ANSWER,
453 Modifier.padding(vertical = padding)
454 )
455
456 ExpandableQuestionCard(
457 "How can I add Shapes from Figma / Google Icons / other ?",
458 CUSTOM_SHAPE_ANSWER,
459 Modifier.padding(vertical = padding)
460 )
461
462 ExpandableQuestionCard(
463 "I finished editing my Shape and want to maintain the changes in my code. What do I have to do?",
464 AnnotatedString(
465 "To preserve your edited shape for future use, export it by clicking the 'Export' button on the Home Screen. This will generate a code snippet representing your shape. Copy this code and integrate it into your project's relevant file to load and use the shape within your project."
466 ),
467 Modifier.padding(vertical = padding)
468 )
469 }
470 }
471 }
472
473 @Composable
EditButtonRownull474 private fun EditButtonRow(onEditClick: () -> Unit, onImportShapeClick: () -> Unit) {
475 Row(
476 Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
477 horizontalArrangement = Arrangement.Center
478 ) {
479 OutlinedButton(onClick = onEditClick, modifier = Modifier.weight(1f)) {
480 Icon(
481 Icons.Default.Edit,
482 contentDescription = "Edit Shape",
483 modifier = Modifier.padding(horizontal = 6.dp)
484 )
485 Text("Edit Shape")
486 }
487 Spacer(Modifier.width(12.dp))
488 Button(onClick = onImportShapeClick, modifier = Modifier.weight(1f)) {
489 Icon(
490 Icons.Default.Add,
491 contentDescription = "New Shape",
492 modifier = Modifier.padding(horizontal = 6.dp)
493 )
494 Text("New Shape")
495 }
496 }
497 }
498
499 @Composable
NewShapeDialognull500 private fun NewShapeDialog(
501 onDismissRequest: () -> Unit,
502 onAddShape: () -> Unit,
503 onImportShape: (() -> List<Feature>) -> Unit
504 ) {
505 var wantsSvgImport by remember { mutableStateOf(true) }
506 val text = remember { mutableStateOf(TextFieldValue("")) }
507 var selectedButton by remember { mutableIntStateOf(0) }
508
509 AlertDialog(
510 onDismissRequest = onDismissRequest,
511 title = { Text("Add Shape") },
512 text = {
513 Column(
514 modifier = Modifier.selectableGroup(),
515 verticalArrangement = Arrangement.SpaceEvenly
516 ) {
517 Text("How do create the new Shape?")
518
519 LabelledRadioButton(
520 selectedButton == 0,
521 "Parametric Shape",
522 ) {
523 selectedButton = 0
524 }
525
526 LabelledRadioButton(
527 selectedButton == 1,
528 "SVG Path Import",
529 ) {
530 selectedButton = 1
531 wantsSvgImport = true
532 }
533
534 LabelledRadioButton(
535 selectedButton == 2,
536 "Serialized Shape Features",
537 ) {
538 selectedButton = 2
539 wantsSvgImport = false
540 }
541
542 ImportTextField(text, wantsSvgImport, selectedButton != 0)
543 }
544 },
545 icon = { Icon(Icons.Default.Add, "Add Shape") },
546 confirmButton = {
547 TextButton(
548 onClick = {
549 when (selectedButton) {
550 0 -> onAddShape()
551 1 -> {
552 onImportShape { SvgPathParser.parseFeatures(text.value.text) }
553 }
554 2 -> onImportShape { FeatureSerializer.parse(text.value.text) }
555 }
556 },
557 ) {
558 Text("Confirm")
559 }
560 },
561 dismissButton = { TextButton(onClick = onDismissRequest) { Text("Cancel") } }
562 )
563 }
564
565 @Composable
SaveConfirmationDialognull566 private fun SaveConfirmationDialog(
567 onDismissRequest: () -> Unit,
568 onConfirmation: () -> Unit,
569 ) {
570 AlertDialog(
571 onDismissRequest = onDismissRequest,
572 title = { Text("Discard Changes?") },
573 text = { Text("Any changes made to the shape will be lost.") },
574 icon = { Icon(Icons.Default.Warning, "Discard Changes?") },
575 confirmButton = {
576 TextButton(
577 onClick = onConfirmation,
578 ) {
579 Text("Save")
580 }
581 },
582 dismissButton = { TextButton(onClick = onDismissRequest) { Text("Discard") } }
583 )
584 }
585
586 @Composable
ExpandableQuestionCardnull587 private fun ExpandableQuestionCard(
588 title: String,
589 content: AnnotatedString,
590 modifier: Modifier = Modifier,
591 ) {
592 var isExpanded by remember { mutableStateOf(false) }
593
594 val rotation by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
595
596 Card(
597 modifier =
598 modifier.fillMaxWidth().animateContentSize(animationSpec = tween(durationMillis = 250)),
599 shape = RoundedCornerShape(16.dp),
600 onClick = { isExpanded = !isExpanded }
601 ) {
602 Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
603 Row(verticalAlignment = Alignment.CenterVertically) {
604 Text(
605 modifier = Modifier.weight(6f),
606 text = title,
607 fontSize = MaterialTheme.typography.titleLarge.fontSize,
608 fontWeight = FontWeight.Bold,
609 overflow = TextOverflow.Ellipsis
610 )
611 IconButton(
612 modifier = Modifier.weight(1f).rotate(rotation),
613 onClick = { isExpanded = !isExpanded }
614 ) {
615 Icon(
616 imageVector = Icons.Default.KeyboardArrowDown,
617 contentDescription = "Expand Content"
618 )
619 }
620 }
621 if (isExpanded) {
622 Text(
623 text = content,
624 fontSize = MaterialTheme.typography.titleSmall.fontSize,
625 fontWeight = FontWeight.Normal,
626 overflow = TextOverflow.Ellipsis
627 )
628 }
629 }
630 }
631 }
632
633 @Composable
ImportTextFieldnull634 fun ImportTextField(text: MutableState<TextFieldValue>, importsSvgPath: Boolean, enabled: Boolean) {
635 var hasBeenEdited by remember { mutableStateOf(false) }
636
637 if (!hasBeenEdited) {
638 text.value = TextFieldValue(if (importsSvgPath) TRIANGE_SVG_PATH else SERIALIZED_TRIANGLE)
639 }
640
641 TextField(
642 value = text.value,
643 onValueChange = {
644 text.value = it
645 hasBeenEdited = true
646 },
647 enabled = enabled,
648 minLines = 10,
649 maxLines = 20,
650 label = {
651 Text(
652 if (importsSvgPath) "Svg Path ('d' attribute in svg files)"
653 else "Serialized Shape Features String"
654 )
655 },
656 )
657 }
658
659 @Composable
LabelledRadioButtonnull660 fun LabelledRadioButton(selected: Boolean, label: String, onClick: () -> Unit) {
661 Row(verticalAlignment = Alignment.CenterVertically) {
662 RadioButton(
663 selected = selected,
664 onClick = onClick,
665 enabled = true,
666 )
667 Text(label)
668 }
669 }
670
AnnotatedStringnull671 private fun AnnotatedString.Builder.addParagraph(header: String, content: String) {
672 appendLine()
673 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(header) }
674 withStyle(ParagraphStyle()) { append(content) }
675 }
676
exportShapenull677 private fun exportShape(activity: FragmentActivity, serialization: String) {
678 println(serialization)
679 val sendIntent: Intent =
680 Intent().apply {
681 action = Intent.ACTION_SEND
682 putExtra(Intent.EXTRA_TEXT, serialization)
683 type = "text/plain"
684 }
685 val shareIntent = Intent.createChooser(sendIntent, null)
686 activity.startActivity(shareIntent)
687 }
688
689 private enum class ScreenTypes {
690 HOME,
691 EDIT,
692 ABOUT,
693 }
694
<lambda>null695 private val CUSTOM_SHAPE_ANSWER = buildAnnotatedString {
696 append("Here's a general approach to import shapes from various sources into your design tool:")
697 appendLine()
698 addParagraph(
699 "1. Export the Shape",
700 "Figma: Export the shape as an SVG file.\nGoogle Icons: Download the icon in SVG format.\nOther Sources: Export the shape in a vector format like SVG or PDF."
701 )
702 addParagraph(
703 "2. Obtain the Path Data",
704 "Open the SVG file in a text editor and locate the '<path>' element. Copy the value of the 'd' attribute. This attribute contains the path data, which is a sequence of commands that define the shape's geometry."
705 )
706 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("Example (triangle)") }
707 withStyle(ParagraphStyle()) { append(TRIANGE_SVG_PATH) }
708
709 addParagraph(
710 "3. Import the Path Data",
711 "In the app, click the 'Import' button. It will open an field that you can paste the path data into. Finally, your shape will be added to the gallery and is ready for editing."
712 )
713 }
714
<lambda>null715 private val SYMMETRIC_SHAPE_ANSWER = buildAnnotatedString {
716 append(
717 "To achieve symmetry in your Morph, focus on the Features of your shapes. These are the special points that guide the morphing process."
718 )
719 appendLine()
720 addParagraph(
721 "1. Identify Symmetrical Areas",
722 "Determine the parts of your shapes that you want to be symmetrical."
723 )
724 addParagraph(
725 "2. Match Feature Types",
726 "Ensure that the Features corresponding to these symmetrical areas have the same type (e.g., both inward or outward indentation)."
727 )
728 addParagraph(
729 "3. Align Feature Points",
730 "Check if the anchor points of these Features align. If not, you may need to split your shapes to create additional Features and fine-tune the alignment."
731 )
732 appendLine()
733 append(
734 "By carefully aligning and matching Features, you can create smooth and symmetrical Morphs."
735 )
736 }
737
738 private const val TRIANGE_SVG_PATH = "M 0 0 0.5 1 1 0 Z"
739 private const val SERIALIZED_TRIANGLE =
740 "V1n0.5,1.5,0.167,0.83,-0.167,0.167,-0.5,-0.5x-0.5,-0.5,-0.5,-0.5,-0.5,-0.5,-0.5,-0.5n-0.5,-0.5,0.167,-0.5,0.83,-0.5,1.5,-0.5x1.5,-0.5,1.5,-0.5,1.5,-0.5,1.5,-0.5n1.5,-0.5,1.167,0.167,0.83,0.83,0.5,1.5x0.5,1.5,0.5,1.5,0.5,1.5,0.5,1.5"
741