├── README.md ├── Slicer.sketchplugin └── Contents │ ├── Resources │ └── UIBundle │ │ └── Contents │ │ └── Resources │ │ ├── MyNibUI.nib │ │ ├── designable.nib │ │ └── keyedobjects.nib │ │ ├── icon-error@2x.png │ │ ├── icon@2x.png │ │ ├── patch-error-bottom-padding@2x.png │ │ ├── patch-error-bottom@2x.png │ │ ├── patch-error-left-padding@2x.png │ │ ├── patch-error-left@2x.png │ │ ├── patch-error-right-padding@2x.png │ │ ├── patch-error-right@2x.png │ │ ├── patch-error-structure@2x.gif │ │ ├── patch-error-top-padding@2x.png │ │ └── patch-error-top@2x.png │ └── Sketch │ ├── commands.js │ ├── core.js │ ├── manifest.json │ ├── sizes.js │ └── sketch-nibui.js ├── appcast.xml └── docs ├── assets ├── 9patch-guide@2x.gif ├── 9patch@2x.gif ├── demo@2x.gif ├── notmuch.css ├── ogimage@2x.png ├── presets@2x.gif ├── repeat@2x.gif └── slicer@2x.png └── index.html /README.md: -------------------------------------------------------------------------------- 1 | # Slicer 2 | 3 | ![Slicer icon](docs/assets/slicer@2x.png) 4 | 5 | Your friendly Sketch slicing helper. 6 | 7 | ![Demo](docs/assets/demo@2x.gif) 8 | 9 | Read more at https://ozzik.github.io/Slicer. 10 | 11 | ## How to install 12 | 1. Download and open ```Slicer-master.zip``` 13 | 2. Open ```Slicer.sketchplugin``` (Sketch will magically install the plugin) 14 | 15 | ## Wha's new 16 | * 0.4.4 (Jun 18) 17 | * Adds support for Sketch 45 auto update system so y'all stop re-downloading from here 18 | * 0.4.3 (Jan 15) 19 | * Fixes unwillingness of short-name layers to get exported to Android (thanks Ronit Klein!) 20 | * 0.4.2 (Dec 8) 21 | * Fixes trimming of transparent pixels when exporting layers (by superhero @girafic) 22 | * 0.4.1 (Nov 8) 23 | * Fixes Sketch 41 bugs (slices not being exported at all) 24 | * 0.4.0 (Oct 9) 25 | * Now exporting layers using exportables for all you wild exporters (for visually ignoring everything below those layers. Slices are still being exported using, well, slices) 26 | * Adds "xxxhdpi" (4x) export size for Android 27 | * 0.3.1 (Sep 26) 28 | * Hello world (that's a funny version number but yep) 29 | 30 | ## Notes 31 | * Tested on Sketch 41 -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/MyNibUI.nib/designable.nib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 155 | 163 | 171 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 196 | 204 | 212 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/MyNibUI.nib/keyedobjects.nib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/MyNibUI.nib/keyedobjects.nib -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/icon-error@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/icon-error@2x.png -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/icon@2x.png -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-bottom-padding@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-bottom-padding@2x.png -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-bottom@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-bottom@2x.png -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-left-padding@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-left-padding@2x.png -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-left@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-left@2x.png -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-right-padding@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-right-padding@2x.png -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-right@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-right@2x.png -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-structure@2x.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-structure@2x.gif -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-top-padding@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-top-padding@2x.png -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-top@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/Slicer.sketchplugin/Contents/Resources/UIBundle/Contents/Resources/patch-error-top@2x.png -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Sketch/commands.js: -------------------------------------------------------------------------------- 1 | @import "core.js"; 2 | 3 | function exportToFolder(context) { 4 | SL.Slicer.exportToFolder(context); 5 | } 6 | 7 | function exportToFolderAs(context) { 8 | SL.Slicer.exportToFolder(context, true); 9 | } -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Sketch/core.js: -------------------------------------------------------------------------------- 1 | @import "sketch-nibui.js"; 2 | @import "sizes.js"; 3 | 4 | var SL = {}; // Namespace 5 | 6 | /* === Core Slicer === */ 7 | SL.Slicer = { 8 | documentMetadata: null, 9 | 10 | exportToFolder: function(context, isRequestNewConfig) { 11 | if (!context.selection.count()) { return; } 12 | 13 | SL.Slicer.documentMetadata = context.document.mutableUIMetadata(); 14 | 15 | var exportConfig = SL.ExportConfig.get(context, isRequestNewConfig), 16 | isSuccess; 17 | 18 | if (!exportConfig) { return; } 19 | 20 | isSuccess = SL.Slicer._export(context, exportConfig); 21 | 22 | if (!isSuccess) { return; } 23 | 24 | if (exportConfig.isOpenFolderPostExport) { 25 | url = NSURL.URLWithString("file://" + exportConfig.directory.replace(/ /g, "%20")); 26 | NSWorkspace.sharedWorkspace().openURL(url); 27 | } else { 28 | context.document.showMessage("All done!"); 29 | } 30 | }, 31 | 32 | _export: function(context, config) { 33 | var selection = context.selection, 34 | doc = context.document, 35 | platforms = { "android": "Android", "ios": "iOS" }, // Fake keys 36 | isSuccess = true, 37 | previousShouldFixArtboardBackground, 38 | previousShouldFixSliceBackground, 39 | isStop; 40 | 41 | // Each layer 42 | for (var s = 0; s < selection.count() && !isStop; s++) { 43 | // Checking for possible background color annoyances 44 | if (selection[s].class() == MSArtboardGroup && selection[s].class && (!selection[s].includeBackgroundColorInExport() || !selection[s].hasBackgroundColor())) { 45 | previousShouldFixArtboardBackground = SL.Slicer._tryToFixArtboardBackground(context, selection[s], config, previousShouldFixArtboardBackground); 46 | isStop = !previousShouldFixArtboardBackground ? true : false; 47 | } else if (selection[s].class() != MSArtboardGroup && doc.currentPage().currentArtboard() && doc.currentPage().currentArtboard().includeBackgroundColorInExport() && doc.currentPage().currentArtboard().hasBackgroundColor()) { 48 | previousShouldFixSliceBackground = SL.Slicer._tryToFixSliceBackground(context, selection[s], config, previousShouldFixSliceBackground); 49 | isStop = !previousShouldFixSliceBackground ? true : false; 50 | } 51 | if (isStop) { continue; } 52 | 53 | // Each platform 54 | for (var platform in platforms) { 55 | if (!config[platform].length) { continue; } 56 | 57 | config.nestedFolder = (config.android.length && config.ios.length) ? platforms[platform] + "/" : ""; 58 | 59 | // Possible 9 patch layer 60 | if (platform == "android" && selection[s].name().indexOf(".9") == selection[s].name().length() - 2 && selection[s].name().length >= 3) { 61 | isSuccess &= SL.NinePatch.try(selection[s], context, config); 62 | } else if (selection[s].class() == "MSSliceLayer") { // Slice 63 | SL.Slicer._exportSlice(selection[s], platform, config, context); 64 | } else { // Layer/group 65 | SL.Slicer._exportLayer(selection[s], platform, config, context); 66 | } 67 | } 68 | } 69 | isSuccess &= !isStop; 70 | 71 | return isSuccess; 72 | }, 73 | 74 | _exportSlice: function(selection, platform, config, context) { 75 | var ancestry = MSImmutableLayerAncestry.ancestryWithMSLayer(selection), 76 | sizeData, 77 | exportFormat, 78 | slice, 79 | fileName; 80 | 81 | for (var i in config[platform]) { 82 | sizeData = config[platform][i]; 83 | sizeData = _SIZES[platform][sizeData]; 84 | exportFormat = MSExportFormat.formatWithScale_name_fileFormat(sizeData.size, "", "png"); 85 | 86 | slice = MSExportRequest.exportRequestsFromLayerAncestry_exportFormats(ancestry, [ exportFormat ])[0]; 87 | SL.Slicer._saveSliceToFile(slice, selection, platform, sizeData, config, context); 88 | } 89 | }, 90 | 91 | _exportLayer: function(selection, platform, config, context) { 92 | var slices, 93 | sizeData, 94 | exportOption, 95 | fileName; 96 | 97 | var rect = selection.absoluteRect().rect(); 98 | 99 | for (var i in config[platform]) { 100 | sizeData = config[platform][i]; 101 | sizeData = _SIZES[platform][sizeData]; 102 | 103 | selection.exportOptions().removeAllExportFormats(); 104 | exportOption = selection.exportOptions().addExportFormat(); 105 | exportOption.setName(""); 106 | exportOption.setScale(sizeData.size); 107 | 108 | slices = MSExportRequest.exportRequestsFromExportableLayer(selection); 109 | slices[0].rect = rect; 110 | 111 | SL.Slicer._saveSliceToFile(slices[0], selection, platform, sizeData, config, context); 112 | } 113 | 114 | selection.exportOptions().removeAllExportFormats(); 115 | }, 116 | 117 | _saveSliceToFile: function(slice, selection, platform, sizeData, config, context) { 118 | var fileName; 119 | 120 | if (platform != "android") { 121 | fileName = (config.nestedFolder || "") + selection.name() + sizeData.name + ".png"; 122 | } else { 123 | fileName = (config.nestedFolder || "") + "drawable-" + sizeData.name + "/" + selection.name() + ".png"; 124 | } 125 | 126 | context.document.saveArtboardOrSlice_toFile(slice, (config.directory + fileName)); 127 | }, 128 | 129 | _tryToFixArtboardBackground: function(context, artboard, config, previousShouldFix) { 130 | var shouldFix; 131 | 132 | if (config.isIgnoreArtboardBackground) { return 2; } 133 | 134 | if (!previousShouldFix) { 135 | shouldFix = SL.UI.showError(context, { 136 | title: "Did you forget to set a background color to some artboards?", 137 | message: "If you continue, the exported artboards will have a transparent color.", 138 | confirmCaption: "Fix and export", 139 | alternativeCaption: "Export anyway" 140 | }); 141 | } else { 142 | shouldFix = previousShouldFix; 143 | } 144 | 145 | if (shouldFix == 1) { 146 | artboard.setHasBackgroundColor(true); 147 | artboard.setIncludeBackgroundColorInExport(true); 148 | } else if (shouldFix == 2) { 149 | SL.ExportConfig.setIgnoreArtboardBackground(true); 150 | } 151 | 152 | return shouldFix; 153 | }, 154 | 155 | _tryToFixSliceBackground: function(context, slice, config, previousShouldFix) { 156 | var shouldFix; 157 | 158 | if (config.isIgnoreSliceBackground) { return 2; } 159 | 160 | if (!previousShouldFix) { 161 | shouldFix = SL.UI.showError(context, { 162 | title: "Heads up: Your slices will have a background color", 163 | message: "If you continue, your slices will have the background color of their artboard instead of being transparent.", 164 | confirmCaption: "Fix and export", 165 | alternativeCaption: "Export anyway" 166 | }); 167 | } else { 168 | shouldFix = previousShouldFix; 169 | } 170 | 171 | if (shouldFix == 1) { 172 | context.document.currentPage().currentArtboard().setIncludeBackgroundColorInExport(false); 173 | } else if (shouldFix == 2) { 174 | SL.ExportConfig.setIgnoreSliceBackground(true); 175 | } 176 | 177 | return shouldFix; 178 | } 179 | }; 180 | 181 | /* === Config === */ 182 | SL.ExportConfig = { 183 | KEY: "Slicer.exportConfig", 184 | 185 | get: function(context, isRequestNewConfig) { 186 | var config = SL.ExportConfig.getSaved(SL.Slicer.documentMetadata); 187 | 188 | if (!config || isRequestNewConfig) { 189 | config = SL.ExportConfig.getNew(context, config); 190 | } 191 | 192 | return config ? SL.ExportConfig._parse(config) : null; 193 | }, 194 | 195 | getSaved: function() { 196 | var configData = SL.Slicer.documentMetadata[SL.ExportConfig.KEY] || ""; 197 | 198 | try { 199 | configData = JSON.parse(configData); 200 | } catch(e) { 201 | configData = null; 202 | } 203 | 204 | return configData; 205 | }, 206 | 207 | getNew: function(context, currentConfig) { 208 | var config, 209 | exportDirectory; 210 | 211 | // Getting preset/sizes 212 | config = SL.ExportConfig._requestPreset(context); 213 | if (!config) { return; } 214 | 215 | // Getting folder export 216 | exportDirectory = SL.UI.requestDirectory(context, currentConfig && currentConfig.directory); 217 | if (!exportDirectory) { return; } 218 | 219 | config.directory = exportDirectory + "/"; 220 | 221 | SL.ExportConfig._save(config); 222 | 223 | return config; 224 | }, 225 | 226 | _parse: function(config) { 227 | var androidSizes = [], 228 | iosSizes = []; 229 | 230 | if (config.android || config.ios) { 231 | androidSizes = config.android; 232 | iosSizes = config.ios; 233 | } else { 234 | switch (config.preset) { 235 | case 0: 236 | iosSizes = [ 0, 1, 2 ]; 237 | break; 238 | case 1: 239 | iosSizes = [ 0, 1 ]; 240 | break; 241 | case 2: 242 | androidSizes = [ 0, 1, 2, 3, 4 ]; 243 | break; 244 | case 3: 245 | androidSizes = [ 1, 3 ]; 246 | break; 247 | } 248 | } 249 | 250 | return { 251 | directory: config.directory, 252 | android: androidSizes, 253 | ios: iosSizes, 254 | isOpenFolderPostExport: config.isOpenFolderPostExport, 255 | isIgnoreArtboardBackground: config.isIgnoreArtboardBackground, 256 | isIgnoreSliceBackground: config.isIgnoreSliceBackground 257 | }; 258 | }, 259 | 260 | _requestPreset: function(context) { 261 | var alertData = SL.UI.requestConfig(context), 262 | nibui = alertData.nibui, 263 | selected = [], 264 | i = 0, 265 | config = {}; 266 | 267 | if (!alertData.isConfirm) { return; } 268 | 269 | // Detecting config 270 | if (nibui.tabView.selectedTabViewItem().label() == "Presets") { 271 | while (i < 4 && !selected.length) { 272 | nibui["radioPreset" + i].state() && selected.push(i); 273 | i++; 274 | } 275 | 276 | config.preset = selected[0]; 277 | } else { 278 | config.android = []; 279 | config.ios = []; 280 | 281 | for (var i in _SIZES.android) { 282 | nibui["checkAndroid" + i].state() && config.android.push(parseInt(i, 10)); 283 | } 284 | for (var i in _SIZES.ios) { 285 | nibui["checkIos" + i].state() && config.ios.push(parseInt(i, 10)); 286 | } 287 | } 288 | 289 | config.isOpenFolderPostExport = nibui.checkOpenFolderPostExport.state(); 290 | 291 | nibui.destroy(); 292 | 293 | return config; 294 | }, 295 | 296 | _save: function(config) { 297 | SL.Slicer.documentMetadata[SL.ExportConfig.KEY] = JSON.stringify(config); 298 | }, 299 | 300 | setIgnoreArtboardBackground: function(value) { 301 | var config = SL.ExportConfig.getSaved(); 302 | config.isIgnoreArtboardBackground = value; 303 | SL.ExportConfig._save(config); 304 | }, 305 | 306 | setIgnoreSliceBackground: function(value) { 307 | var config = SL.ExportConfig.getSaved(); 308 | config.isIgnoreSliceBackground = value; 309 | SL.ExportConfig._save(config); 310 | } 311 | }; 312 | 313 | /* === UI === */ 314 | SL.UI = { 315 | requestConfig: function(context) { 316 | var alert = NSAlert.alloc().init(); 317 | 318 | alert.setMessageText("Let's export!"); 319 | alert.setInformativeText("Select a size preset or go make things complicated..."); 320 | alert.addButtonWithTitle("Export"); 321 | alert.addButtonWithTitle("Cancel"); 322 | alert.setIcon(NSImage.alloc().initWithContentsOfFile(context.plugin.urlForResourceNamed("UIBundle/Contents/Resources/icon@2x.png").path())); 323 | 324 | var nibui = new NibUI(context, "UIBundle", "MyNibUI", 325 | [ 326 | "tabView", 327 | "radioPreset0", "radioPreset1", "radioPreset2", "radioPreset3", 328 | "checkIos0", "checkIos1", "checkIos2", 329 | "checkAndroid0", "checkAndroid1", "checkAndroid2", "checkAndroid3", "checkAndroid4", 330 | "checkOpenFolderPostExport" 331 | ] 332 | ); 333 | 334 | alert.setAccessoryView(nibui.view); 335 | 336 | // Updating state to saved config 337 | nibui.tabView.selectTabViewItemAtIndex(1); 338 | nibui.tabView.selectTabViewItemAtIndex(0); 339 | 340 | nibui.radioPreset0.becomeFirstResponder(); 341 | 342 | var alertAction = alert.runModal(); 343 | 344 | return { 345 | nibui: nibui, 346 | isConfirm: alertAction == NSAlertFirstButtonReturn 347 | }; 348 | }, 349 | 350 | requestDirectory: function(context, latestPath) { 351 | var panel = NSOpenPanel.openPanel(), 352 | defaultPath, 353 | path; 354 | 355 | if (context.document.fileURL() && !latestPath) { 356 | defaultPath = context.document.fileURL().URLByDeletingLastPathComponent(); 357 | } else { 358 | defaultPath = NSURL.URLWithString(latestPath || "~/Desktop"); 359 | } 360 | 361 | panel.setDirectoryURL(defaultPath); 362 | panel.setCanChooseDirectories(true); 363 | panel.setAllowsMultipleSelection(true); 364 | panel.setCanCreateDirectories(true); 365 | panel.setMessage("Select a directory to export to"); 366 | 367 | if (panel.runModal() == NSOKButton) { 368 | path = panel.URL().path(); 369 | } 370 | 371 | return path; 372 | }, 373 | 374 | showError: function(context, options) { 375 | var alert = NSAlert.alloc().init(); 376 | 377 | alert.setMessageText(options.title); 378 | alert.setInformativeText(options.message); 379 | 380 | options.confirmCaption && alert.addButtonWithTitle(options.confirmCaption); 381 | options.alternativeCaption && alert.addButtonWithTitle(options.alternativeCaption); 382 | alert.addButtonWithTitle(options.confirmCaption ? "Cancel" : "Gotcha"); 383 | 384 | if (options.image) { 385 | var imageView = NSImageView.alloc().initWithFrame(NSMakeRect(0, 0, options.imageWidth, options.imageHeight)); 386 | imageView.setImageScaling(NSScaleToFit); 387 | imageView.setImage(NSImage.alloc().initWithContentsOfFile(context.plugin.urlForResourceNamed("UIBundle/Contents/Resources/" + options.image).path())); 388 | alert.setAccessoryView(imageView); 389 | } 390 | 391 | alert.setIcon(NSImage.alloc().initWithContentsOfFile(context.plugin.urlForResourceNamed("UIBundle/Contents/Resources/icon-error@2x.png").path())); 392 | 393 | var alertAction = alert.runModal(); 394 | 395 | if (alertAction == NSAlertFirstButtonReturn) { 396 | return 1 397 | } else { 398 | return (options.alternativeCaption && alertAction == NSAlertSecondButtonReturn) ? 2 : 0; 399 | } 400 | } 401 | }; 402 | 403 | /* === 9-Patch === */ 404 | SL.NinePatch = { 405 | try: function(target, context, config) { 406 | var layers = target.layers(), 407 | inferData = SL.NinePatch._infer(layers), 408 | size, 409 | fileName, 410 | currentPage, 411 | tempPage; 412 | 413 | if (!inferData.didFind) { 414 | SL.UI.showError(context, { 415 | title: "😱 Can't export \"" + layers[0].parentGroup().name() + "\"", 416 | message: "Exporting 9-patch works only when the group holds 2 groups: one holding 4 \"patch lines\" and another holding the actual slice content (their names don't really matter).", 417 | image: "patch-error-structure@2x.gif", 418 | imageWidth: 270, 419 | imageHeight: 150 420 | }); 421 | return; 422 | } 423 | // Validating patch sizes for 1.5x 424 | if (config.android.indexOf(1) != -1 && !SL.NinePatch._validate(context, layers, inferData)) { return; } // Bad patch 425 | 426 | // Dummy holding page 427 | currentPage = context.document.currentPage(); 428 | tempPage = context.document.addBlankPage(); 429 | 430 | for (var i in config.android) { 431 | size = config.android[i]; 432 | fileName = (config.nestedFolder || "") + "drawable-" + _SIZES.android[size].name + "/" + target.name() + ".png"; 433 | 434 | SL.NinePatch._create(target, inferData.iSlice, inferData.iPatch, tempPage, context, _SIZES.android[size].size, fileName, config.directory); 435 | } 436 | 437 | context.document.removePage(tempPage); 438 | context.document.setCurrentPage(currentPage); 439 | 440 | return true; 441 | }, 442 | 443 | _infer: function(layers) { 444 | var BLACK = MSColor.blackColor(); 445 | var i = 0, 446 | patch, 447 | slice, 448 | iPatch = 0, 449 | iSlice = 0, 450 | currentLayer, 451 | subLayers, 452 | didFind; 453 | 454 | // Skipping check if layer count is bad 455 | if (layers.count() != 2) { 456 | i = 5; 457 | } 458 | 459 | // Inferring 9patch + slice 460 | while ((!patch && !slice) && i < 2) { 461 | currentLayer = layers.objectAtIndex(i); 462 | blackCount = 0; 463 | 464 | // 9patch 465 | if (currentLayer.class() == "MSLayerGroup") { 466 | subLayers = currentLayer.layers() 467 | for (var s = 0; s < subLayers.count(); s++) { 468 | if (subLayers.objectAtIndex(s).class() == MSSliceLayer) { continue; } 469 | blackCount += (subLayers.objectAtIndex(s).style().fills().objectAtIndex(0).color().isEqual(BLACK)) ? 1 : 0; 470 | } 471 | 472 | if (blackCount == 4) { 473 | patch = currentLayer; 474 | slice = layers.objectAtIndex(iSlice); 475 | iPatch = i; 476 | iSlice = i ? 0 : 1; 477 | didFind = true; 478 | } 479 | } 480 | 481 | i++; 482 | } 483 | 484 | return { 485 | iPatch: iPatch, 486 | iSlice: iSlice, 487 | didFind: didFind 488 | }; 489 | }, 490 | 491 | _validate: function(context, layers, data) { 492 | var isValid = true, 493 | patches = layers[data.iPatch].layers(), 494 | patchName, 495 | isHorizontalPatch, 496 | error, 497 | frame, 498 | i = 0, 499 | sliceName = layers[data.iSlice].parentGroup().name(), 500 | shouldFix; 501 | 502 | // Slice size 503 | frame = layers[data.iSlice].frame(); 504 | isValid = (frame.width() % 2 == 0 && frame.height() % 2 == 0); 505 | 506 | if (!isValid) { 507 | SL.UI.showError(context, { 508 | title: "😱 Can't export \"" + sliceName + "\" at 1.5x", 509 | message: "The width and height of your slice's content must be even (current size: " + frame.width() + "x" + frame.height() + "). Try stretching your slice or just adding an extra space pixel." 510 | }); 511 | } 512 | 513 | // Slice sizes 514 | while (i < patches.count() && isValid) { 515 | frame = patches[i].frame(); 516 | patchName = SL.NinePatch._detectPatch(frame, layers[data.iSlice]); 517 | isHorizontalPatch = (patchName == "top" || patchName == "bottom"); 518 | 519 | // Width/height 520 | isValid = isHorizontalPatch ? (frame.width() % 2 == 0) : (frame.height() % 2 == 0); 521 | if (!isValid) { 522 | error = "Your " + patchName + " patch's " + (isHorizontalPatch ? "width" : "height") + " should be even "; 523 | error += "(current is " + (isHorizontalPatch ? frame.width() : frame.height()) + "px)."; 524 | 525 | shouldFix = SL.UI.showError(context, { 526 | title: "😱 Can't export \"" + sliceName + "\" at 1.5x", 527 | message: error, 528 | confirmCaption: "Fix and continue", 529 | image: "patch-error-" + patchName + "@2x.png", 530 | imageWidth: 134, 531 | imageHeight: 98 532 | }); 533 | 534 | if (shouldFix) { 535 | SL.NinePatch._fixSize(patches[i], isHorizontalPatch); 536 | isValid = true; 537 | } else { 538 | i = patches.count(); // Stopping 539 | } 540 | } else { // Verifying padding 541 | isValid = isHorizontalPatch ? ((frame.x() - layers[data.iSlice].frame().x()) % 2 == 0) : ((frame.y() - layers[data.iSlice].frame().y()) % 2 == 0); 542 | 543 | if (!isValid) { 544 | error = "Your " + patchName + " patch's padding should be even "; 545 | error += "(current is " + (isHorizontalPatch ? (frame.x() - layers[data.iSlice].frame().x()) : (frame.y() - layers[data.iSlice].frame().y())) + "px)."; 546 | shouldFix = SL.UI.showError(context, { 547 | title: "😱 Can't export \"" + sliceName + "\" at 1.5x", 548 | message: error, 549 | confirmCaption: "Fix and continue", 550 | image: "patch-error-" + patchName + "-padding@2x.png", 551 | imageWidth: 134, 552 | imageHeight: 98 553 | }); 554 | 555 | if (shouldFix) { 556 | SL.NinePatch._fixPadding(patches[i], isHorizontalPatch); 557 | isValid = true; 558 | } else { 559 | i = patches.count(); // Stopping 560 | } 561 | 562 | } else { 563 | i++; 564 | } 565 | } 566 | } 567 | 568 | return isValid; 569 | }, 570 | 571 | _fixPadding: function(patch, isHorizontalPatch) { 572 | if (isHorizontalPatch) { 573 | patch.frame().setX(patch.frame().x() - 1); 574 | } else { 575 | patch.frame().setY(patch.frame().y() - 1); 576 | } 577 | }, 578 | 579 | _fixSize: function(patch, isHorizontalPatch) { 580 | if (isHorizontalPatch) { 581 | patch.frame().setWidth(patch.frame().width() > 1 ? (patch.frame().width() + 1) : 2); 582 | } else { 583 | patch.frame().setHeight(patch.frame().height() > 1 ? (patch.frame().height() + 1) : 2); 584 | } 585 | }, 586 | 587 | _detectPatch: function(curPatch, slice) { 588 | var patch = ""; 589 | 590 | if (curPatch.x() >= slice.frame().x() && curPatch.x() < slice.frame().x() + slice.frame().width()) { 591 | patch = (curPatch.y() < slice.frame().y()) ? "top" : "bottom"; 592 | } else { 593 | patch = (curPatch.x() < slice.frame().x()) ? "left" : "right"; 594 | } 595 | 596 | return patch; 597 | }, 598 | 599 | _create: function(target, iSlice, iPatch, tempPage, context, factor, fileName, exportPath) { 600 | var ditto = target.duplicate(), 601 | dittoSliceOriginalX, 602 | dittoSliceOriginalY; 603 | 604 | ditto.parentGroup().removeLayer(ditto); 605 | tempPage.addLayers([ditto]); 606 | 607 | ditto.frame().setX(ditto.frame().x() + ditto.frame().width() + 10); 608 | ditto.frame().setY(ditto.frame().y()); 609 | 610 | var dittoPatch = ditto.layers().objectAtIndex(iPatch), 611 | dittoSlice = ditto.layers().objectAtIndex(iSlice), 612 | dittoPatchLayers = dittoPatch.layers(), 613 | curPatch; 614 | 615 | for (var i = 0; i < dittoPatchLayers.count(); i++) { 616 | curPatch = dittoPatchLayers.objectAtIndex(i).frame(); 617 | 618 | // Top/bottom 619 | if (curPatch.x() >= dittoSlice.frame().x() && curPatch.x() < dittoSlice.frame().x() + dittoSlice.frame().width()) { 620 | curPatch.setWidth(curPatch.width() * factor); 621 | curPatch.setX((curPatch.x() - 1) * factor + 1); 622 | 623 | if (curPatch.y() > dittoSlice.frame().y()) { 624 | curPatch.setY((curPatch.y() - 1) * factor + 1); 625 | } 626 | } else { // Left/right 627 | curPatch.setHeight(curPatch.height() * factor); 628 | curPatch.setY((curPatch.y() - 1) * factor + 1); 629 | 630 | if (curPatch.x() > dittoSlice.frame().x()) { 631 | curPatch.setX((curPatch.x() - 1) * factor + 1); 632 | } 633 | } 634 | } 635 | 636 | dittoSliceOriginalX = dittoSlice.frame().x(); 637 | dittoSliceOriginalY = dittoSlice.frame().y(); 638 | 639 | dittoSlice.multiplyBy(factor); 640 | dittoSlice.makeRectIntegral(); 641 | dittoSlice.frame().setX(dittoSliceOriginalX); 642 | dittoSlice.frame().setY(dittoSliceOriginalY); 643 | 644 | dittoPatch.resizeToFitChildrenWithOption(0); 645 | ditto.resizeToFitChildrenWithOption(0); 646 | 647 | var ancestry = MSImmutableLayerAncestry.ancestryWithMSLayer(ditto), 648 | exportFormat = MSExportFormat.formatWithScale_name_fileFormat(1, "", "png"); 649 | slice = MSExportRequest.exportRequestsFromLayerAncestry_exportFormats(ancestry, [ exportFormat ])[0]; 650 | 651 | context.document.saveArtboardOrSlice_toFile(slice, exportPath + fileName); 652 | 653 | tempPage.removeLayer(ditto); 654 | } 655 | }; -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Sketch/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "author" : "Oz Pinhas", 3 | "commands" : [ 4 | { 5 | "script" : "commands.js", 6 | "handler" : "exportToFolder", 7 | "name" : "Export to Folder", 8 | "shortcut" : "cmd e", 9 | "identifier" : "exportToFolder" 10 | }, 11 | { 12 | "script" : "commands.js", 13 | "handler" : "exportToFolderAs", 14 | "name" : "Export to Folder As...", 15 | "shortcut" : "cmd alt e", 16 | "identifier" : "exportToFolderAs" 17 | } 18 | ], 19 | "menu": { 20 | "isRoot": true, 21 | "items": [ 22 | { 23 | "title": "Slicer", 24 | "items": [ 25 | "exportToFolder", 26 | "exportToFolderAs", 27 | "do9patch" 28 | ] 29 | } 30 | ] 31 | }, 32 | "identifier" : "co.ozzik.slicer", 33 | "version" : "0.4.4", 34 | "description" : "Exporting life", 35 | "authorEmail" : "hey@ozzik.co", 36 | "name" : "Slicer", 37 | "appcast": "https://raw.githubusercontent.com/ozzik/slicer/master/appcast.xml" 38 | } 39 | -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Sketch/sizes.js: -------------------------------------------------------------------------------- 1 | var _SIZES = { 2 | android: [ 3 | { 4 | size: 1, 5 | name: "mdpi" 6 | }, 7 | { 8 | size: 1.5, 9 | name: "hdpi" 10 | }, 11 | { 12 | size: 2, 13 | name: "xhdpi" 14 | }, 15 | { 16 | size: 3, 17 | name: "xxhdpi" 18 | }, 19 | { 20 | size: 4, 21 | name: "xxxhdpi" 22 | } 23 | ], 24 | ios: [ 25 | { 26 | size: 1, 27 | name: "" 28 | }, 29 | { 30 | size: 2, 31 | name: "@2x" 32 | }, 33 | { 34 | size: 3, 35 | name: "@3x" 36 | }, 37 | ] 38 | }; -------------------------------------------------------------------------------- /Slicer.sketchplugin/Contents/Sketch/sketch-nibui.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015 Google Inc. 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 | function NibUI(context, bundleResourceName, nibName, bindViewNames) { 18 | bindViewNames = bindViewNames || []; 19 | 20 | var bundlePath = context.plugin.urlForResourceNamed(bundleResourceName).path(); 21 | this._bundle = NSBundle.bundleWithPath(bundlePath); 22 | 23 | var superclass = NSClassFromString('NSObject'); 24 | 25 | // create a class name that doesn't exist yet. note that we can't reuse the same 26 | // definition lest Sketch will throw an MOJavaScriptException when binding the UI, 27 | // probably due to JavaScript context / plugin lifecycle incompatibility 28 | 29 | var tempClassName; 30 | while (true) { 31 | tempClassName = 'NibOwner' + _randomId(); 32 | if (NSClassFromString(tempClassName) == null) { 33 | break; 34 | } 35 | } 36 | 37 | var me = this; 38 | 39 | // register the temporary class and set up instance methods that will be called for 40 | // each bound view 41 | 42 | this._cls = MOClassDescription.allocateDescriptionForClassWithName_superclass_(tempClassName, superclass); 43 | 44 | bindViewNames.forEach(function(bindViewName) { 45 | var setterName = 'set' + bindViewName.substring(0, 1).toUpperCase() + bindViewName.substring(1); 46 | me._cls.addInstanceMethodWithSelector_function_( 47 | NSSelectorFromString(setterName + ':'), 48 | function(arg) { 49 | me[bindViewName] = arg; 50 | }); 51 | }); 52 | 53 | this._cls.registerClass(); 54 | this._nibOwner = NSClassFromString(tempClassName).alloc().init(); 55 | 56 | // Radio button thingy 57 | var selector = NSSelectorFromString('radioButtonSelected:'); 58 | this._cls.addInstanceMethodWithSelector_function_( 59 | selector, 60 | function() {}); 61 | 62 | var tloPointer = MOPointer.alloc().initWithValue(null); 63 | 64 | if (this._bundle.loadNibNamed_owner_topLevelObjects_(nibName, this._nibOwner, tloPointer)) { 65 | var topLevelObjects = tloPointer.value(); 66 | for (var i = 0; i < topLevelObjects.count(); i++) { 67 | var obj = topLevelObjects.objectAtIndex(i); 68 | if (obj.className().endsWith('View')) { 69 | this.view = obj; 70 | break; 71 | } 72 | } 73 | } else { 74 | throw new Error('Could not load nib'); 75 | } 76 | } 77 | 78 | function _randomId() { 79 | return (1000000 * Math.random()).toFixed(0); 80 | } 81 | 82 | /** 83 | * Helper function for making click handlers (for use in NSButton.setAction). 84 | */ 85 | NibUI.prototype.attachTargetAndAction = function(view, fn) { 86 | if (!this._clickActionNames) { 87 | this._clickActionNames = {}; 88 | } 89 | 90 | var clickActionName; 91 | while (true) { 92 | clickActionName = 'zzzTempClickAction' + _randomId(); 93 | if (!(clickActionName in this._clickActionNames)) { 94 | break; 95 | } 96 | } 97 | 98 | this._clickActionNames[clickActionName] = true; 99 | 100 | var selector = NSSelectorFromString(clickActionName + ':'); 101 | this._cls.addInstanceMethodWithSelector_function_( 102 | selector, 103 | function() { 104 | fn(); 105 | }); 106 | 107 | view.setTarget(this._nibOwner); 108 | view.setAction(selector); 109 | }; 110 | 111 | /** 112 | * Release all resources. 113 | */ 114 | NibUI.prototype.destroy = function() { 115 | this._bundle.unload(); 116 | }; -------------------------------------------------------------------------------- /appcast.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Slicer 5 | https://raw.githubusercontent.com/ozzik/slicer/master/appcast.xml 6 | Exporting life 7 | en 8 | 9 | Version 0.4.4 10 | 11 | 13 |
  • Minor update v0.4.4
  • 14 | 15 | ]]> 16 |
    17 | 18 |
    19 |
    20 |
    -------------------------------------------------------------------------------- /docs/assets/9patch-guide@2x.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/docs/assets/9patch-guide@2x.gif -------------------------------------------------------------------------------- /docs/assets/9patch@2x.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/docs/assets/9patch@2x.gif -------------------------------------------------------------------------------- /docs/assets/demo@2x.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/docs/assets/demo@2x.gif -------------------------------------------------------------------------------- /docs/assets/notmuch.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: 'Source Sans Pro', sans-serif; 5 | background: #fff; 6 | font-size: 15px; 7 | color: #515151; 8 | } 9 | p { margin: 0; } 10 | ol { 11 | margin: 5px 0; 12 | padding: 0 0 0 20px; 13 | list-style: disc; 14 | } 15 | 16 | /* Flex things */ 17 | .box-h { 18 | display: flex; 19 | flex-direction: row; 20 | } 21 | .box-v { 22 | display: flex; 23 | flex-direction: column; 24 | } 25 | .box-main { flex: 0 1 auto; } 26 | 27 | 28 | .wrapper { 29 | max-width: 780px; 30 | margin: 0 auto; 31 | } 32 | 33 | .header { 34 | position: relative; 35 | background: #9E80D0; 36 | height: 230px; 37 | color: #fff; 38 | 39 | } 40 | .header-p { margin: 0; } 41 | 42 | /* Header */ 43 | .header-wrapper { 44 | max-width: 380px; 45 | margin: 0 auto; 46 | padding-top: 130px; 47 | } 48 | .header-meta { 49 | margin: -50px 0 0 40px; 50 | } 51 | .slicer-image { 52 | width: 82px; 53 | height: 123px; 54 | background: url("slicer@2x.png") no-repeat; 55 | background-size: 82px; 56 | } 57 | 58 | h1 { 59 | margin: 0; 60 | font-weight: normal;; 61 | font-size: 30px; 62 | } 63 | 64 | .header-buttons { margin-top: 20px; } 65 | .button { 66 | display: inline-block; 67 | background: #fff; 68 | box-shadow: 0px 0px 2px 0px rgba(0,0,0,0.10), 0px 4px 4px 0px rgba(0,0,0,0.10); 69 | border-radius: 6px; 70 | color: #494949; 71 | font-weight: bold; 72 | font-size: 15px; 73 | text-decoration: none; 74 | padding: 8px 20px; 75 | transition: all .4s cubic-bezier(.5, 1, 0, 1.1); 76 | transition-property: box-shadow, background, transform; 77 | } 78 | .button:hover { 79 | transform: translate3d(0,-1px,0); 80 | box-shadow: 0px 0px 2px 0px rgba(0,0,0,0.10), 0px 5px 10px 0px rgba(0,0,0,0.10); 81 | } 82 | .button:active { background: #e8e8e8; } 83 | 84 | .button.secondary { 85 | margin-left: 6px; 86 | background: #3e3252; 87 | color: #fff; 88 | } 89 | .button.secondary:hover { 90 | transform: translate3d(0,-1px,0); 91 | background: #594875; 92 | box-shadow: 0px 0px 2px 0px rgba(0,0,0,0.10), 0px 5px 10px 0px rgba(0,0,0,0.10); 93 | } 94 | .button.secondary:active { background: #2d243c; } 95 | 96 | /* Content */ 97 | .content { margin-top: 80px; } 98 | 99 | .demo { 100 | margin: 0 auto 40px; 101 | border-radius: 4px; 102 | border: 1px solid #E5E5E5; 103 | } 104 | .column { 105 | width: 26%; 106 | text-align: center; 107 | } 108 | .column:not(:first-child) { margin-left: 8%; } 109 | 110 | h2 { 111 | margin: 0 auto 3px; 112 | font-wight: 700; 113 | font-size: 17px; 114 | color: #141414; 115 | } 116 | 117 | .separator { 118 | width: 100%; 119 | margin: 60px 0; 120 | height: 1px; 121 | background: #E5E5E5; 122 | } 123 | 124 | .extra .meta { margin-right: 20px; } 125 | 126 | /* Footer */ 127 | .footer { 128 | margin-top: 60px; 129 | padding: 20px 0; 130 | text-align: center; 131 | color: rgba(0,0,0,.4); 132 | font-size: 14px; 133 | } 134 | .footer a { 135 | color: #64577A; 136 | opacity: .6; 137 | transition: opacity .4s cubic-bezier(.5, 1, 0, 1.1); 138 | } 139 | .footer a:hover { opacity: .8; } -------------------------------------------------------------------------------- /docs/assets/ogimage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/docs/assets/ogimage@2x.png -------------------------------------------------------------------------------- /docs/assets/presets@2x.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/docs/assets/presets@2x.gif -------------------------------------------------------------------------------- /docs/assets/repeat@2x.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/docs/assets/repeat@2x.gif -------------------------------------------------------------------------------- /docs/assets/slicer@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ozzik/Slicer/6dcee5fba3565bb47faaa6ae945b3d08ec3a0fdb/docs/assets/slicer@2x.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Slicer - your friendly Sketch slicing helper 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
    16 |
    17 |
    18 |
    19 |

    Slicer

    20 |

    Your friendly Sketch slicing plugin

    21 |
    22 | 23 | View on Github 24 |
    25 |
    26 |
    27 |
    28 |
    29 |
    30 | 31 |
    32 |
    33 |
    34 | 35 |

    Presets woot!

    36 |

    Select a preset (or customize one) and your slices are named and sorted by folders neatly

    37 |
    38 |
    39 | 40 |

    Repeat!

    41 |

    Setup your slices once and hit ⌘ + E endlessly to export again using the same settings

    42 |
    43 |
    44 | 45 |

    9-patch the world!

    46 |

    Draw 4 black rectangles (i.e. patches) once and watch them exported into all Android sizes

    47 |
    48 |
    49 |
    50 |
    51 |
    52 |
    53 |
    54 |

    Some 9-patch guidance

    55 |

    In order for the dark arts of 9-patch to work please follow these:

    56 |
      57 |
    1. Draw 4 black colored patches and group them
    2. 58 |
    3. Group your content (might be best to include an actual slice or a mask inside it to make sure its size is as you want it)
    4. 59 |
    5. Group the 2 groups and name that group after your slice, ending with a ".9"
    6. 60 |
    61 |
    62 | 63 |
    64 |
    65 | 71 | 72 | 73 | --------------------------------------------------------------------------------