Skip to content

Commit

Permalink
feat: add support for dark mode images and semantic colors (#11097)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ewanharris authored and keerthi1032 committed Sep 3, 2019
1 parent da842f4 commit 1a8ae85
Show file tree
Hide file tree
Showing 9 changed files with 311 additions and 10 deletions.
65 changes: 65 additions & 0 deletions apidoc/Titanium/UI/UI.yml
Expand Up @@ -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 <Titanium.UI.fetchSemanticColor> 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 <Titanium.UI.semanticColorType> 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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions build/lib/packager.js
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions build/scons-xcode-project-build.js
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions 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';
57 changes: 57 additions & 0 deletions 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}`);
}
}
};
12 changes: 12 additions & 0 deletions iphone/Classes/TiUIiOSProxy.m
Expand Up @@ -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
130 changes: 124 additions & 6 deletions iphone/cli/commands/_build.js
Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions tests/Resources/semantic.colors.json
@@ -0,0 +1,6 @@
{
"textColor": {
"dark": "#ff85e2",
"light": "#ff1f1f"
}
}
38 changes: 38 additions & 0 deletions 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);

}
});
});

0 comments on commit 1a8ae85

Please sign in to comment.