diff --git a/crates/dc_bundle/src/legacy_definition/view/node_style.rs b/crates/dc_bundle/src/legacy_definition/view/node_style.rs index 58e814ef0..a7933e3d8 100644 --- a/crates/dc_bundle/src/legacy_definition/view/node_style.rs +++ b/crates/dc_bundle/src/legacy_definition/view/node_style.rs @@ -104,6 +104,8 @@ pub struct NodeStyle { pub aspect_ratio: Number, pub pointer_events: PointerEvents, pub meter_data: Option, + + pub img_replacement_res_name: Option, } impl Default for NodeStyle { @@ -151,6 +153,7 @@ impl Default for NodeStyle { aspect_ratio: Number::default(), pointer_events: PointerEvents::default(), meter_data: None, + img_replacement_res_name: None, } } } diff --git a/crates/dc_bundle/src/legacy_definition/view/view_style.rs b/crates/dc_bundle/src/legacy_definition/view/view_style.rs index abdb69698..1efca8f1c 100644 --- a/crates/dc_bundle/src/legacy_definition/view/view_style.rs +++ b/crates/dc_bundle/src/legacy_definition/view/view_style.rs @@ -212,6 +212,10 @@ impl ViewStyle { if self.node_style.meter_data != other.node_style.meter_data { delta.node_style.meter_data = other.node_style.meter_data.clone(); } + if self.node_style.img_replacement_res_name != other.node_style.img_replacement_res_name { + delta.node_style.img_replacement_res_name = + other.node_style.img_replacement_res_name.clone(); + } delta } } diff --git a/crates/figma_import/src/transform_flexbox.rs b/crates/figma_import/src/transform_flexbox.rs index 2ceaad2f8..fe2fe1877 100644 --- a/crates/figma_import/src/transform_flexbox.rs +++ b/crates/figma_import/src/transform_flexbox.rs @@ -1468,6 +1468,13 @@ fn visit_node( } } + // Check to see if there is additional plugin data to render the node use a resource drawable. + if let Some(res_data) = plugin_data { + if let Some(res_name) = res_data.get("image_replacement_res_name") { + style.node_style.img_replacement_res_name = Some(res_name.into()); + } + } + // Figure out the ViewShape from the node type. let view_shape = match &node.data { figma_schema::NodeData::BooleanOperation { vector, .. } diff --git a/designcompose/src/main/assets/figma/DesignSwitcherDoc_Ljph4e3sC0lHcynfXpoh9f.dcf b/designcompose/src/main/assets/figma/DesignSwitcherDoc_Ljph4e3sC0lHcynfXpoh9f.dcf index 038ef2200..fd0694e80 100644 Binary files a/designcompose/src/main/assets/figma/DesignSwitcherDoc_Ljph4e3sC0lHcynfXpoh9f.dcf and b/designcompose/src/main/assets/figma/DesignSwitcherDoc_Ljph4e3sC0lHcynfXpoh9f.dcf differ diff --git a/designcompose/src/main/java/com/android/designcompose/DesignFrame.kt b/designcompose/src/main/java/com/android/designcompose/DesignFrame.kt index bfee363eb..c7a130821 100644 --- a/designcompose/src/main/java/com/android/designcompose/DesignFrame.kt +++ b/designcompose/src/main/java/com/android/designcompose/DesignFrame.kt @@ -197,6 +197,10 @@ internal fun DesignFrame( } ) } + val imageReplacement = loadDrawable(style.node_style.img_replacement_res_name, LocalContext.current) + if (customImage == null) { + customImage = imageReplacement + } // Get the modeValues used to resolve variable values val modeValues = VariableManager.currentModeValues(view.explicit_variable_modes) @@ -294,7 +298,9 @@ internal fun DesignFrame( horizontalArrangement = layoutInfo.arrangement, verticalAlignment = layoutInfo.alignment ) { - content() + if (imageReplacement == null) { + content() + } } } } @@ -365,7 +371,9 @@ internal fun DesignFrame( verticalArrangement = layoutInfo.arrangement, horizontalAlignment = layoutInfo.alignment ) { - content() + if (imageReplacement == null) { + content() + } } } } @@ -690,7 +698,9 @@ internal fun DesignFrame( m = m.then(layoutInfo.selfModifier) DesignVariableExplicitModeValues(modeValues) { DesignFrameLayout(m, view, layoutId, rootLayoutId, layoutState, designScroll) { - content() + if (imageReplacement == null) { + content() + } } } } diff --git a/designcompose/src/main/java/com/android/designcompose/Utils.kt b/designcompose/src/main/java/com/android/designcompose/Utils.kt index 2ebcf5df1..0d53b7562 100644 --- a/designcompose/src/main/java/com/android/designcompose/Utils.kt +++ b/designcompose/src/main/java/com/android/designcompose/Utils.kt @@ -636,6 +636,11 @@ internal fun mergeStyles(base: ViewStyle, override: ViewStyle): ViewStyle { } else { base.node_style.meter_data } + nodeStyle.img_replacement_res_name = if (override.node_style.img_replacement_res_name.isPresent) { + override.node_style.img_replacement_res_name + } else { + base.node_style.img_replacement_res_name + } style.layout_style = layoutStyle.build() style.node_style = nodeStyle.build() return style.build() @@ -749,6 +754,7 @@ internal fun NodeStyle.asBuilder(): NodeStyle.Builder { builder.aspect_ratio = aspect_ratio builder.pointer_events = pointer_events builder.meter_data = meter_data + builder.img_replacement_res_name = img_replacement_res_name return builder } @@ -795,6 +801,7 @@ internal fun defaultNodeStyle(): NodeStyle.Builder { builder.aspect_ratio = com.android.designcompose.serdegen.Number.Undefined() builder.pointer_events = PointerEvents.Auto() builder.meter_data = Optional.empty() + builder.img_replacement_res_name = Optional.empty() builder.hyperlink = Optional.empty() return builder } @@ -1343,27 +1350,17 @@ internal fun Background.asBrush( is Background.Image -> { val backgroundImage = this val imageTransform = backgroundImage.transform.asSkiaMatrix() - if (DebugNodeManager.getUseLocalRes().value) { - backgroundImage.res_name.orElse(null)?.let { - val resId = - appContext.resources.getIdentifier(it, "drawable", appContext.packageName) - if (resId != Resources.ID_NULL) { - val bitmap = - BitmapFactoryWithCache.loadResource(appContext.resources, resId) - return Pair( - RelativeImageFill( - image = bitmap, - imageDensity = density, - displayDensity = density, - imageTransform = imageTransform, - scaleMode = backgroundImage.scale_mode - ), - backgroundImage.opacity - ) - } else { - Log.w(TAG, "No drawable resource $it found") - } - } + loadDrawable(backgroundImage.res_name, appContext)?.let { + return Pair( + RelativeImageFill( + image = it, + imageDensity = density, + displayDensity = density, + imageTransform = imageTransform, + scaleMode = backgroundImage.scale_mode + ), + backgroundImage.opacity + ) } val imageFillAndDensity = backgroundImage.key.orElse(null)?.let { document.image(it.value, density) } @@ -1481,6 +1478,20 @@ internal fun Background.asBrush( return null } +internal fun loadDrawable(resName: Optional, appContext: Context): Bitmap? { + if (DebugNodeManager.getUseLocalRes().value) { + resName.orElse(null)?.let { + val resId = appContext.resources.getIdentifier(it, "drawable", appContext.packageName) + if (resId != Resources.ID_NULL) { + return BitmapFactoryWithCache.loadResource(appContext.resources, resId) + } else { + Log.w(TAG, "No drawable resource $it found") + } + } + } + return null +} + internal fun com.android.designcompose.serdegen.Path.asPath( density: Float, scaleX: Float, diff --git a/integration-tests/validation/src/main/java/com/android/designcompose/testapp/validation/examples/ImageUpdateTest.kt b/integration-tests/validation/src/main/java/com/android/designcompose/testapp/validation/examples/ImageUpdateTest.kt index b90f1d022..6ca1869fa 100644 --- a/integration-tests/validation/src/main/java/com/android/designcompose/testapp/validation/examples/ImageUpdateTest.kt +++ b/integration-tests/validation/src/main/java/com/android/designcompose/testapp/validation/examples/ImageUpdateTest.kt @@ -23,7 +23,7 @@ import com.android.designcompose.annotation.DesignDoc // TEST Image Update Test. After this loads, rename #Stage in the Figma doc. After the app // updates, // rename it back to #Stage. The image should reload correctly. -@DesignDoc(id = "oQw7kiy94fvdVouCYBC9T0") +@DesignDoc(id = "6jg63z15XG1GdIt3F62Ojy") interface ImageUpdateTest { @DesignComponent(node = "#Stage") fun Main() {} } diff --git a/integration-tests/validation/src/main/res/drawable-hdpi/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable-hdpi/ic_stop_sign_1.png new file mode 100644 index 000000000..0b6e92b6e Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable-hdpi/ic_stop_sign_1.png differ diff --git a/integration-tests/validation/src/main/res/drawable-mdpi/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable-mdpi/ic_stop_sign_1.png new file mode 100644 index 000000000..c0b820750 Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable-mdpi/ic_stop_sign_1.png differ diff --git a/integration-tests/validation/src/main/res/drawable-xhdpi/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable-xhdpi/ic_stop_sign_1.png new file mode 100644 index 000000000..96b108cd6 Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable-xhdpi/ic_stop_sign_1.png differ diff --git a/integration-tests/validation/src/main/res/drawable-xxhdpi/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable-xxhdpi/ic_stop_sign_1.png new file mode 100644 index 000000000..796712dad Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable-xxhdpi/ic_stop_sign_1.png differ diff --git a/integration-tests/validation/src/main/res/drawable-xxxhdpi/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable-xxxhdpi/ic_stop_sign_1.png new file mode 100644 index 000000000..53e359014 Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable-xxxhdpi/ic_stop_sign_1.png differ diff --git a/integration-tests/validation/src/main/res/drawable-zh-hdpi/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable-zh-hdpi/ic_stop_sign_1.png new file mode 100644 index 000000000..bbfb0c23d Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable-zh-hdpi/ic_stop_sign_1.png differ diff --git a/integration-tests/validation/src/main/res/drawable-zh-mdpi/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable-zh-mdpi/ic_stop_sign_1.png new file mode 100644 index 000000000..1e402e8f3 Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable-zh-mdpi/ic_stop_sign_1.png differ diff --git a/integration-tests/validation/src/main/res/drawable-zh-xhdpi/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable-zh-xhdpi/ic_stop_sign_1.png new file mode 100644 index 000000000..3c972ab5a Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable-zh-xhdpi/ic_stop_sign_1.png differ diff --git a/integration-tests/validation/src/main/res/drawable-zh-xxhdpi/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable-zh-xxhdpi/ic_stop_sign_1.png new file mode 100644 index 000000000..a89f776b5 Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable-zh-xxhdpi/ic_stop_sign_1.png differ diff --git a/integration-tests/validation/src/main/res/drawable-zh-xxxhdpi/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable-zh-xxxhdpi/ic_stop_sign_1.png new file mode 100644 index 000000000..c1d0392f8 Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable-zh-xxxhdpi/ic_stop_sign_1.png differ diff --git a/integration-tests/validation/src/main/res/drawable-zh/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable-zh/ic_stop_sign_1.png new file mode 100644 index 000000000..92356ce9a Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable-zh/ic_stop_sign_1.png differ diff --git a/integration-tests/validation/src/main/res/drawable/ic_stop_sign_1.png b/integration-tests/validation/src/main/res/drawable/ic_stop_sign_1.png new file mode 100644 index 000000000..2e445e185 Binary files /dev/null and b/integration-tests/validation/src/main/res/drawable/ic_stop_sign_1.png differ diff --git a/support-figma/extended-layout-plugin/manifest.json b/support-figma/extended-layout-plugin/manifest.json index 054774009..57c195c4d 100644 --- a/support-figma/extended-layout-plugin/manifest.json +++ b/support-figma/extended-layout-plugin/manifest.json @@ -18,7 +18,9 @@ { "name": "Generate String Resource", "command": "localization" }, { "name": "Clear String Resource", "command": "clear-localization" }, { "name": "Export Images", "command": "export-images" }, - { "name": "Clear Image Resource", "command": "clear-image-res" } + { "name": "Clear Image Resource", "command": "clear-image-res" }, + { "name": "Export Vectors", "command": "export-vectors" }, + { "name": "Clear Vector Resource", "command": "clear-vector-res" } ] } ], diff --git a/support-figma/extended-layout-plugin/src/code.ts b/support-figma/extended-layout-plugin/src/code.ts index a58d8f8b0..df789e78a 100644 --- a/support-figma/extended-layout-plugin/src/code.ts +++ b/support-figma/extended-layout-plugin/src/code.ts @@ -18,6 +18,7 @@ import * as Utils from "./utils"; import * as Localization from "./localization-module"; import * as DesignSpecs from "./design-spec-module"; import * as ImageRes from "./image-res-module"; +import * as VectorRes from "./vector-res-module"; // Warning component. interface ClippyWarningRun { @@ -564,6 +565,16 @@ if (figma.command === "sync") { }; } else if (figma.command === "clear-image-res") { ImageRes.clear(); +} else if (figma.command === "export-vectors") { + VectorRes.exportAllVectorsAsync(); + figma.ui.onmessage = (msg) => { + if (msg.msg === "show-node") { + Utils.showNode(msg.node); + } else if (msg.msg === "update-vector-res-name") { + } + } +} else if (figma.command === "clear-vector-res") { + } else if (figma.command === "move-plugin-data") { function movePluginDataWithKey(node: BaseNode, key: string) { // Read the private plugin data, write to shared diff --git a/support-figma/extended-layout-plugin/src/image-res-module.ts b/support-figma/extended-layout-plugin/src/image-res-module.ts index 3d470ef90..47682a4c3 100644 --- a/support-figma/extended-layout-plugin/src/image-res-module.ts +++ b/support-figma/extended-layout-plugin/src/image-res-module.ts @@ -242,3 +242,12 @@ function parseCachedImageHashToResMap(imageHashToResNameData?: string) { ); return cachedImageHashToResMap; } + +export function loadImageDrawableResNames(): Array { + let imageResNames = new Array(); + let cachedHashToResMap = loadExportedImages(); + cachedHashToResMap.forEach((resName, _) => imageResNames.push(resName)); + let cachedHashToResMapExcluded = loadNonExportedImages(); + cachedHashToResMapExcluded.forEach((resName, _) => imageResNames.push(resName)); + return imageResNames; +} diff --git a/support-figma/extended-layout-plugin/src/ui.html b/support-figma/extended-layout-plugin/src/ui.html index eec8025dc..ac81eba67 100644 --- a/support-figma/extended-layout-plugin/src/ui.html +++ b/support-figma/extended-layout-plugin/src/ui.html @@ -398,6 +398,18 @@ + +
+
Vector Frames:
+
  • Vector frames will be exported as png files, supporting ldpi, mdpi, hdpi, xhdpi and xxhdpi densities.
  • +
    +
    + +
    + +
    diff --git a/support-figma/extended-layout-plugin/src/vector-res-module.ts b/support-figma/extended-layout-plugin/src/vector-res-module.ts new file mode 100644 index 000000000..3ab03e714 --- /dev/null +++ b/support-figma/extended-layout-plugin/src/vector-res-module.ts @@ -0,0 +1,175 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { loadImageDrawableResNames } from "./image-res-module"; +import * as Utils from "./utils"; + +const IMAGE_REPLACEMENT_RES_NAME = "image_replacement_res_name"; + +// Node id to vector root(frame node) +const vectorFrames = new Map(); +// Res name to VectorRes map. +const vectorResMap = new Map(); +// Image res names that vector exported images can not use +var existingImageDrawableResNames : Array; + +// ldpi: 0.75x, mdpi: 1x, hdpi: 1.5x, xhdpi: 2x, xxhdpi: 3x, xxxhdpi: 4x according to: +// https://developer.android.com/training/multiscreen/screendensities +// Ignores the 0.5x export option. +interface VectorRes { + resName: string; + nodeId: string; + ldpiBytes: Uint8Array; + mdpiBytes: Uint8Array; + hdpiBytes: Uint8Array; + xhdpiBytes: Uint8Array; + xxhdpiBytes: Uint8Array; +} + +export async function exportAllVectorsAsync(){ + await figma.loadAllPagesAsync(); + + vectorFrames.clear(); + vectorResMap.clear(); + + const vectorNodes = figma.root.findAll((node) => node.type === "VECTOR"); + + for (const vectorNode of vectorNodes) { + const frameAncestor = findFrameAncestor(vectorNode); + if (frameAncestor && isVectorRoot(frameAncestor)) { + vectorFrames.set(frameAncestor.id, frameAncestor); + } + } + + existingImageDrawableResNames = loadImageDrawableResNames(); + for (const [frameNodeId, frameNode] of vectorFrames) { + const cachedResName = getResName(frameNode); + var resName = cachedResName + ? cachedResName + : `ic_${Utils.toSnakeCase(frameNode.name)}`; + var index = 0; + while (vectorResMap.has(resName) || existingImageDrawableResNames.includes(resName)) { + index += 1; + resName = resName + "_" + index; + } + + let vectorRes = { + resName: resName, + nodeId: frameNodeId, + ldpiBytes: await frameNode.exportAsync({ + format: "PNG", + colorProfile: "SRGB", + constraint: { type: "SCALE", value: 0.75 }, + }), + mdpiBytes: await frameNode.exportAsync({ + format: "PNG", + colorProfile: "SRGB", + constraint: { type: "SCALE", value: 1 }, + }), + hdpiBytes: await frameNode.exportAsync({ + format: "PNG", + colorProfile: "SRGB", + constraint: { type: "SCALE", value: 1.5 }, + }), + xhdpiBytes: await frameNode.exportAsync({ + format: "PNG", + colorProfile: "SRGB", + constraint: { type: "SCALE", value: 2 }, + }), + xxhdpiBytes: await frameNode.exportAsync({ + format: "PNG", + colorProfile: "SRGB", + constraint: { type: "SCALE", value: 3 }, + }), + xxxhdpiBytes: await frameNode.exportAsync({ + format: "PNG", + colorProfile: "SRGB", + constraint: { type: "SCALE", value: 4 }, + }), + }; + vectorResMap.set(resName, vectorRes); + setResName(frameNode, resName); + } + + figma.showUI(__html__, { width: 600, height: 600 }); + figma.ui.postMessage({ + msg: "vector-export", + vectorResArray: Array.from(vectorResMap), + existingImageDrawableResNames: existingImageDrawableResNames, + }); +} + +function getResName(node: SceneNode): string { + return node.getSharedPluginData( + Utils.SHARED_PLUGIN_NAMESPACE, + IMAGE_REPLACEMENT_RES_NAME + ); +} + +function setResName(node: SceneNode, resName: string) { + node.setSharedPluginData( + Utils.SHARED_PLUGIN_NAMESPACE, + IMAGE_REPLACEMENT_RES_NAME, + resName + ); +} + +function findFrameAncestor(node: PageNode | SceneNode): FrameNode | undefined { + if (node.parent?.type === "FRAME") { + return node.parent!!; + } + + if (node.parent?.type === "GROUP") { + return findFrameAncestor(node.parent!!); + } + + return undefined; +} + +function isVectorRoot(frameNode: FrameNode): boolean { + for (const childNode of frameNode.children) { + if (!isVectorOrTextOrVectorGroup(childNode)) { + return false; + } + } + return true; +} + +// Returns true if the node is a vector node or a group node with vector as descendants. +function isVectorOrTextOrVectorGroup(node: SceneNode): boolean { + if (node.type === "VECTOR") { + return true; + } + if (node.type === "TEXT") { + return true; + } + if (node.type === "GROUP") { + return isVectorGroup(node); + } + return false; +} + +function isVectorGroup(groupNode: GroupNode): boolean { + for (const childNode of groupNode.children) { + if (childNode.type === "GROUP") { + if (!isVectorGroup(childNode)) { + return false; + } + } else if (childNode.type !== "VECTOR" && childNode.type != "TEXT") { + return false; + } + } + return true; +}