From 1a8ae85c90c8a3a9be08744bae0b154bc125fd25 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Tue, 3 Sep 2019 22:04:21 +0100 Subject: [PATCH] feat: add support for dark mode images and semantic colors (#11097) * feat(ios): add support for specifying dark mode images Implements part of TIMOB-27126 * feat: add support for semantic colors This adds a cross platfom method for loading semanitc colors, on iOS 11+ we will use the native Ti.UI.iOS.fetchSemanticColor to load the right color, in all other cases we use the Ti.UI.semanticColorType and the provided json file to obtain the correct value Fixes TIMOB-27126 * fix: correct check for iOS namespace * build: apply rollup configuration to xcode project build * fix: fall back to json file when using below ios 13 * docs: correct summary * test: add tests * fix(android): define getter/setter for semanticcolortype property Due to the way Ti.UI works on Android we cant reliably track the changes, by implementing the property with the get/set syntax we can track the property changes ourselves and reliably return the correct result * feat: support setting alpha per color * refactor: allow setting alpha as a 0-100 range * fix(ios): guard in sdk 11 check * docs: add docs --- apidoc/Titanium/UI/UI.yml | 65 +++++++++ build/lib/packager.js | 6 +- build/scons-xcode-project-build.js | 6 +- .../ti.internal/extensions/ti/index.js | 1 + .../ti.internal/extensions/ti/ti.ui.js | 57 ++++++++ iphone/Classes/TiUIiOSProxy.m | 12 ++ iphone/cli/commands/_build.js | 130 +++++++++++++++++- tests/Resources/semantic.colors.json | 6 + tests/Resources/ti.ui.addontest.js | 38 +++++ 9 files changed, 311 insertions(+), 10 deletions(-) create mode 100644 common/Resources/ti.internal/extensions/ti/ti.ui.js create mode 100644 tests/Resources/semantic.colors.json create mode 100644 tests/Resources/ti.ui.addontest.js diff --git a/apidoc/Titanium/UI/UI.yml b/apidoc/Titanium/UI/UI.yml index 4978f8deb51..181682e09b9 100644 --- a/apidoc/Titanium/UI/UI.yml +++ b/apidoc/Titanium/UI/UI.yml @@ -133,6 +133,44 @@ description: | If a color value is not valid on iOS, the default color is applied, whereas, on Android, the color yellow is applied. + #### Dark Mode + + In iOS 13 Apple introduced support for users to adopt a system-wide Dark Mode setting where the screens, view, menus, and controls use a darker color palette. You can read more about this in the Apple Human Interface Guidelines. + + There are two aspects to dark mode that can be specified for your app, colors and images. + + ##### Specifying Dark Mode colors + + To specify colors for dark mode, also known as semantic colors, first create a file called `semantic.colors.json` in the Resources directory for classic applications, or in the assets directory for Alloy applications. Then you can specify color names in the following format: + + ```` + { + "textColor": { // the name for your color + "dark": { + "color": "#ff85e2", // hex color code to be set + "alpha": "50.0" // can be set from a range of 0-100 + }, + "light": "#ff1f1f" + } + } + ```` + + To reference these colors in your application use the API, this is a cross platform API that on iOS 13 and above will use the native method that checks the users system-wide setting, and in all other instances will check the property and return the correct color for the current setting. + + ##### Specifying Dark Mode images + + Note: Dark Mode images are iOS only. + + To specify dark mode images, use the `-dark` suffix on the image name. When building your app the images are set as the dark mode variant, then refer to images as normal and iOS will select the correct image dependent on the users system-wide setting. + + For example given an image `logo.png` with `@2x` and `@3x` variants, the following dark mode images should exist: + + * logo-dark.png + * logo-dark@2x.png + * logo-dark@3x.png + + And you would reference the image as before using `logo-dark.png` + extends: Titanium.Module since: "0.4" @@ -201,6 +239,16 @@ methods: type: Number constants: Titanium.UI.UNIT_* + - name: fetchSemanticColor + summary: | + Fetches the correct color to be used with a UI element dependent on the users current dark mode setting on iOS 13 and above, or the [Titanium.UI.semanticColorType](Titanium.UI.semanticColorType) setting in other instances. + parameters: + - name: colorName + summary: Name of the semantic color defined in the applications colorset. + type: String + returns: + - type: String + since: "8.2.0" properties: - name: ANIMATION_CURVE_EASE_IN @@ -2332,6 +2380,23 @@ properties: type: Number permission: read-only + - name: SEMANTIC_COLOR_TYPE_DARK + summary: Return the dark value from the applications colorset + type: String + permission: read-only + since: "8.2.0" + + - name: SEMANTIC_COLOR_TYPE_LIGHT + summary: Return the light value from the applications colorset. + type: String + permission: read-only + since: "8.2.0" + + - name: semanticColorType + summary: When running on Android, iOS 10 or lower, or Windows the value to return form the applications colorset. + type: String + constants: Titanium.UI.SEMANTIC_COLOR_TYPE_* + since: "8.2.0" - name: SIZE summary: SIZE behavior for UI layout. diff --git a/build/lib/packager.js b/build/lib/packager.js index da3feb477da..2d196d9b67e 100644 --- a/build/lib/packager.js +++ b/build/lib/packager.js @@ -273,10 +273,12 @@ class Packager { input: `${tmpBundleDir}/Resources/ti.main.js`, plugins: [ resolve(), - commonjs(), + commonjs({ + ignore: [ '/semantic.colors.json' ] + }), babel(babelOptions) ], - external: [ './app', 'com.appcelerator.aca' ] + external: [ './app', 'com.appcelerator.aca', '/semantic.colors.json' ] }); // write the bundle to disk diff --git a/build/scons-xcode-project-build.js b/build/scons-xcode-project-build.js index 62a9541ba92..98a7af3b8f3 100644 --- a/build/scons-xcode-project-build.js +++ b/build/scons-xcode-project-build.js @@ -84,10 +84,12 @@ async function generateBundle(inputDir, outputDir) { input: `${inputDir}/ti.main.js`, plugins: [ resolve(), - commonjs(), + commonjs({ + ignore: [ '/semantic.colors.json' ] + }), babel(babelOptions) ], - external: [ './app', 'com.appcelerator.aca' ] + external: [ './app', 'com.appcelerator.aca', '/semantic.colors.json' ] }); // write the bundle to disk diff --git a/common/Resources/ti.internal/extensions/ti/index.js b/common/Resources/ti.internal/extensions/ti/index.js index ed72d681ebb..f3ecff49a47 100644 --- a/common/Resources/ti.internal/extensions/ti/index.js +++ b/common/Resources/ti.internal/extensions/ti/index.js @@ -1,2 +1,3 @@ // Load extensions to polyfill our own APIs import './ti.blob'; +import './ti.ui'; diff --git a/common/Resources/ti.internal/extensions/ti/ti.ui.js b/common/Resources/ti.internal/extensions/ti/ti.ui.js new file mode 100644 index 00000000000..a2fab79f361 --- /dev/null +++ b/common/Resources/ti.internal/extensions/ti/ti.ui.js @@ -0,0 +1,57 @@ +/** + * Appcelerator Titanium Mobile + * Copyright (c) 2019 by Axway, Inc. All Rights Reserved. + * Licensed under the terms of the Apache Public License + * Please see the LICENSE included with this distribution for details. + */ + +let colorset; +let osVersion; + +// As Android passes a new instance of Ti.UI to every JS file we can't just +// Ti.UI within this file, we must call kroll.binding to get the Titanium +// namespace that is passed in with require and that deal with the .UI +// namespace that is on that directly. +let uiModule = Ti.UI; +if (Ti.Android) { + uiModule = kroll.binding('Titanium').Titanium.UI; +} + +uiModule.SEMANTIC_COLOR_TYPE_LIGHT = 'light'; +uiModule.SEMANTIC_COLOR_TYPE_DARK = 'dark'; + +// We need to track this manually with a getter/setter +// due to the same reasons we use uiModule instead of Ti.UI +let currentColorType = uiModule.SEMANTIC_COLOR_TYPE_LIGHT; +Object.defineProperty(uiModule, 'semanticColorType', { + get: () => { + return currentColorType; + }, + set: (colorType) => { + currentColorType = colorType; + } +}); + +uiModule.fetchSemanticColor = function fetchSemanticColor (colorName) { + if (!osVersion) { + osVersion = parseInt(Ti.Platform.version.split('.')[0]); + } + + if (Ti.App.iOS && osVersion >= 13) { + return Ti.UI.iOS.fetchSemanticColor(colorName); + } else { + if (!colorset) { + try { + colorset = require('/semantic.colors.json'); // eslint-disable-line import/no-absolute-path + } catch (error) { + console.error('Failed to require colors file at /semantic.colors.json'); + return; + } + } + try { + return colorset[colorName][uiModule.semanticColorType].color || colorset[colorName][uiModule.semanticColorType]; + } catch (error) { + console.log(`Failed to lookup color for ${colorName}`); + } + } +}; diff --git a/iphone/Classes/TiUIiOSProxy.m b/iphone/Classes/TiUIiOSProxy.m index 87170e58226..d021d3084cb 100644 --- a/iphone/Classes/TiUIiOSProxy.m +++ b/iphone/Classes/TiUIiOSProxy.m @@ -873,5 +873,17 @@ - (id)createWebViewProcessPool:(id)args MAKE_SYSTEM_PROP(INJECTION_TIME_DOCUMENT_END, WKUserScriptInjectionTimeAtDocumentEnd); #endif +- (TiColor *)fetchSemanticColor:(id)color +{ + ENSURE_SINGLE_ARG(color, NSString); + +#if IS_SDK_IOS_11 + if ([TiUtils isIOSVersionOrGreater:@"11.0"]) { + return [[TiColor alloc] initWithColor:[UIColor colorNamed:color] name:nil]; + } +#endif + return [[TiColor alloc] initWithColor:UIColor.blackColor name:@"black"]; +} + @end #endif diff --git a/iphone/cli/commands/_build.js b/iphone/cli/commands/_build.js index e59e145fb39..f31e2114f27 100644 --- a/iphone/cli/commands/_build.js +++ b/iphone/cli/commands/_build.js @@ -5746,7 +5746,7 @@ iOSBuilder.prototype.copyResources = function copyResources(next) { this.logger.info(__('Creating assets image set')); const assetCatalog = path.join(this.buildDir, 'Assets.xcassets'), imageSets = {}, - imageNameRegExp = /^(.*?)(@[23]x)?(~iphone|~ipad)?\.(png|jpg)$/; + imageNameRegExp = /^(.*?)(-dark)?(@[23]x)?(~iphone|~ipad)?\.(png|jpg)$/; Object.keys(imageAssets).forEach(function (file) { const imageName = imageAssets[file].name, @@ -5771,13 +5771,21 @@ iOSBuilder.prototype.copyResources = function copyResources(next) { }; } - imageSets[imageSetRelPath].images.push({ - idiom: !match[3] ? 'universal' : match[3].replace('~', ''), + const imageSet = { + idiom: !match[4] ? 'universal' : match[3].replace('~', ''), filename: imageName + '.' + imageExt, - scale: !match[2] ? '1x' : match[2].replace('@', '') - }); - } + scale: !match[3] ? '1x' : match[3].replace('@', '') + }; + + if (match[2]) { + imageSet.appearances = [ { + appearance: 'luminosity', + value: 'dark' + } ]; + } + imageSets[imageSetRelPath].images.push(imageSet); + } resourcesToCopy[file] = imageAssets[file]; resourcesToCopy[file].isImage = true; }, this); @@ -5794,6 +5802,116 @@ iOSBuilder.prototype.copyResources = function copyResources(next) { }, this); }, + function generateSemanticColors() { + const colorsFile = path.join(this.projectDir, 'Resources', 'iphone', 'semantic.colors.json'); + const assetCatalog = path.join(this.buildDir, 'Assets.xcassets'); + const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + + function hexToRgb(hex) { + let alpha = 1; + let color = hex; + if (hex.color) { + alpha = hex.alpha / 100; // convert from 0-100 range to 0-1 range + color = hex.color; + } + // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") + color = color.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b); + + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + alpha: alpha.toFixed(3) + } : null; + } + + if (!fs.existsSync(colorsFile)) { + return; + } + const colors = fs.readJSONSync(colorsFile); + + for (const [ color, colorValue ] of Object.entries(colors)) { + const colorDir = path.join(assetCatalog, `${color}.colorset`); + + if (!colorValue.light) { + this.logger.warn(`Skipping ${color} as it does not include a light value`); + continue; + } + + if (!colorValue.dark) { + this.logger.warn(`Skipping ${color} as it does not include a dark value`); + continue; + } + + const defaultRGB = hexToRgb(colorValue.default || colorValue.light); + const lightRGB = hexToRgb(colorValue.light); + const darkRGB = hexToRgb(colorValue.dark); + + const colorSource = { + info: { + version: 1, + author: 'xcode' + }, + colors: [] + }; + + // Default + colorSource.colors.push({ + idiom: 'universal', + color: { + 'color-space': 'srgb', + components: { + red: `${defaultRGB.r}`, + green: `${defaultRGB.g}`, + blue: `${defaultRGB.b}`, + alpha: `${defaultRGB.alpha}` + } + } + }); + + // Light + colorSource.colors.push({ + idiom: 'universal', + appearances: [ { + appearance: 'luminosity', + value: 'light' + } ], + color: { + 'color-space': 'srgb', + components: { + red: `${lightRGB.r}`, + green: `${lightRGB.g}`, + blue: `${lightRGB.b}`, + alpha: `${lightRGB.alpha}` + } + } + }); + + // Dark + colorSource.colors.push({ + idiom: 'universal', + appearances: [ { + appearance: 'luminosity', + value: 'dark' + } ], + color: { + 'color-space': 'srgb', + components: { + red: `${darkRGB.r}`, + green: `${darkRGB.g}`, + blue: `${darkRGB.b}`, + alpha: `${darkRGB.alpha}` + } + } + }); + + fs.ensureDirSync(colorDir); + fs.writeJsonSync(path.join(colorDir, 'Contents.json'), colorSource); + this.unmarkBuildDirFile(path.join(colorDir, 'Contents.json')); + } + }, + function copyResources() { this.logger.debug(__('Copying resources')); Object.keys(resourcesToCopy).forEach(function (file) { diff --git a/tests/Resources/semantic.colors.json b/tests/Resources/semantic.colors.json new file mode 100644 index 00000000000..0be98e2e31c --- /dev/null +++ b/tests/Resources/semantic.colors.json @@ -0,0 +1,6 @@ +{ + "textColor": { + "dark": "#ff85e2", + "light": "#ff1f1f" + } +} diff --git a/tests/Resources/ti.ui.addontest.js b/tests/Resources/ti.ui.addontest.js new file mode 100644 index 00000000000..eabe5609f4f --- /dev/null +++ b/tests/Resources/ti.ui.addontest.js @@ -0,0 +1,38 @@ +/* + * Appcelerator Titanium Mobile + * Copyright (c) 2011-Present by Appcelerator, Inc. All Rights Reserved. + * Licensed under the terms of the Apache Public License + * Please see the LICENSE included with this distribution for details. + */ +/* eslint-env mocha */ +/* eslint no-unused-expressions: "off", import/no-absolute-path: "off" */ +'use strict'; +const should = require('./utilities/assertions'); + +describe('Titanium.UI', function () { + it('.SEMANTIC_COLOR_TYPE_DARK', function () { + should(Ti.UI).have.a.constant('SEMANTIC_COLOR_TYPE_DARK').which.is.a.string; + }); + + it('.SEMANTIC_COLOR_TYPE_LIGHT', function () { + should(Ti.UI).have.a.constant('SEMANTIC_COLOR_TYPE_LIGHT').which.is.a.string; + }); + + it('semanticColorType default', function () { + should(Ti.UI.semanticColorType).eql(Ti.UI.SEMANTIC_COLOR_TYPE_LIGHT); + }); + + it('fetchSemanticColor', function () { + var isiOS13 = (Ti.Platform.osname === 'iphone' || Ti.Platform.osname === 'ipad') && (parseInt(Ti.Platform.version.split('.')[0]) >= 13); + const semanticColors = require('/semantic.colors.json'); + + if (isiOS13) { + should(Ti.UI.fetchSemanticColor('textColor')).be.an.string; + } else { + should(Ti.UI.fetchSemanticColor('textColor')).equal(semanticColors.textColor.light); + Ti.UI.semanticColorType = Ti.UI.SEMANTIC_COLOR_TYPE_DARK; + should(Ti.UI.fetchSemanticColor('textColor')).equal(semanticColors.textColor.dark); + + } + }); +});