From 6d7c1ff295cd339caaa79794190a16dbdc7dd65a Mon Sep 17 00:00:00 2001 From: Panayot Cankov Date: Mon, 25 Sep 2017 18:32:00 +0300 Subject: [PATCH] Avoid applying CSS multiple times (#4784) * Move the applyStyleFromScope to onLoaded, when the views are created and id or className properties are set the CSS selectors are queried and applied multiple times * Condense the changes when applying properties --- apps/app/ui-tests-app/main-page.xml | 2 +- tests/app/testRunner.ts | 2 +- tests/app/ui/lifecycle/lifecycle-tests.ts | 51 ++- .../app/ui/lifecycle/pages/button-counter.ts | 11 + tests/app/ui/lifecycle/pages/page-two.css | 15 + tests/app/ui/lifecycle/pages/page-two.xml | 8 + .../segmented-bar-tests-native.ios.ts | 8 +- tests/app/ui/styling/style-tests.ts | 5 +- tests/app/ui/styling/value-source-tests.ts | 13 + .../component-builder/component-builder.ts | 14 +- .../ui/core/properties/properties.d.ts | 6 +- .../ui/core/properties/properties.ts | 64 +-- .../ui/core/view-base/view-base.d.ts | 29 +- .../ui/core/view-base/view-base.ts | 234 ++-------- tns-core-modules/ui/core/view/view.android.ts | 1 - tns-core-modules/ui/core/view/view.d.ts | 24 +- tns-core-modules/ui/dialogs/dialogs-common.ts | 4 +- tns-core-modules/ui/frame/frame-common.ts | 1 - .../layouts/grid-layout/grid-layout-common.ts | 25 +- tns-core-modules/ui/page/page-common.ts | 77 +--- tns-core-modules/ui/page/page.d.ts | 9 - tns-core-modules/ui/styling/style-scope.ts | 433 ++++++++++++------ tns-core-modules/ui/styling/style/style.d.ts | 17 +- tns-core-modules/ui/styling/style/style.ts | 5 +- tsconfig.shared.json | 2 +- 25 files changed, 536 insertions(+), 524 deletions(-) create mode 100644 tests/app/ui/lifecycle/pages/page-two.css create mode 100644 tests/app/ui/lifecycle/pages/page-two.xml diff --git a/apps/app/ui-tests-app/main-page.xml b/apps/app/ui-tests-app/main-page.xml index ca1831e0f..7aa36c395 100644 --- a/apps/app/ui-tests-app/main-page.xml +++ b/apps/app/ui-tests-app/main-page.xml @@ -15,4 +15,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/tests/app/testRunner.ts b/tests/app/testRunner.ts index ed5169e21..e13ae4fce 100644 --- a/tests/app/testRunner.ts +++ b/tests/app/testRunner.ts @@ -359,7 +359,7 @@ function showReportPage(finalMessage: string) { setTimeout(() => { messageContainer.dismissSoftInput(); (messageContainer.nativeViewProtected).scrollTo(0, 0); - }); + }, 10); } } diff --git a/tests/app/ui/lifecycle/lifecycle-tests.ts b/tests/app/ui/lifecycle/lifecycle-tests.ts index c3346c8b0..69a733557 100644 --- a/tests/app/ui/lifecycle/lifecycle-tests.ts +++ b/tests/app/ui/lifecycle/lifecycle-tests.ts @@ -1,7 +1,7 @@ import * as helper from "../helper"; import * as btnCounter from "./pages/button-counter"; import * as TKUnit from "../../TKUnit"; -import { isIOS } from "tns-core-modules/platform"; +import { isIOS, isAndroid } from "tns-core-modules/platform"; // Integration tests that asser sertain runtime behavior, lifecycle events atc. @@ -118,4 +118,51 @@ export function test_navigating_away_does_not_excessively_reset() { // NOTE: Recycling may mess this up so feel free to change the test, // but ensure a reasonable amount of native setters were called when the views navigate away assert(1); -} \ No newline at end of file +} + +export function test_css_sets_properties() { + const page = helper.navigateToModule("ui/lifecycle/pages/page-two"); + const buttons = ["btn1", "btn2", "btn3", "btn4"].map(id => page.getViewById(id)); + buttons.forEach(btn => { + TKUnit.assertEqual(btn.colorSetNativeCount, 1, `Expected ${btn.id}'s native color to propagated exactly once when inflating from xml.`); + TKUnit.assertEqual(btn.colorPropertyChangeCount, 1, `Expected ${btn.id}'s colorChange to be fired exactly once when inflating from xml.`); + }); + + buttons.forEach(btn => { + btn.className = ""; + }); + + const expectedChangesAfterClearingClasses = [1, 2, 2, 2]; + for (var i = 0; i < buttons.length; i++) { + TKUnit.assertEqual(buttons[i].colorSetNativeCount, expectedChangesAfterClearingClasses[i], `Expected ${buttons[i].id} native set after clear.`); + TKUnit.assertEqual(buttons[i].colorPropertyChangeCount, expectedChangesAfterClearingClasses[i], `Expected ${buttons[i].id} change notifications after clear.`); + } + + buttons[0].className = "nocolor"; + buttons[1].className = "red"; + buttons[2].className = "blue"; + buttons[3].className = "red blue"; + + const expectedChangesAfterResettingClasses = [1, 3, 3, 3]; + for (let i = 0; i < buttons.length; i++) { + TKUnit.assertEqual(buttons[i].colorSetNativeCount, expectedChangesAfterResettingClasses[i], `Expected ${buttons[i].id} native set after classes are reapplied.`); + TKUnit.assertEqual(buttons[i].colorPropertyChangeCount, expectedChangesAfterResettingClasses[i], `Expected ${buttons[i].id} change notifications classes are reapplied.`); + } + + const stack: any = page.getViewById("stack"); + page.content = null; + + for (let i = 0; i < buttons.length; i++) { + TKUnit.assertEqual(buttons[i].colorSetNativeCount, expectedChangesAfterResettingClasses[i], `Expected ${buttons[i].id} native set to not be called when removed from page.`); + TKUnit.assertEqual(buttons[i].colorPropertyChangeCount, expectedChangesAfterResettingClasses[i], `Expected ${buttons[i].id} change notifications for css properties to not occur when removed from page.`); + } + + page.content = stack; + + // TODO: The check counts here should be the same as the counts before removing from the page. + const expectedNativeSettersAfterReaddedToPage = isAndroid ? [2, 4, 4, 4] : expectedChangesAfterResettingClasses; + for (let i = 0; i < buttons.length; i++) { + TKUnit.assertEqual(buttons[i].colorSetNativeCount, expectedNativeSettersAfterReaddedToPage[i], `Expected ${buttons[i].id} native set to not be called when added to page.`); + TKUnit.assertEqual(buttons[i].colorPropertyChangeCount, expectedChangesAfterResettingClasses[i], `Expected ${buttons[i].id} change notifications for css properties to not occur when added to page.`); + } +} diff --git a/tests/app/ui/lifecycle/pages/button-counter.ts b/tests/app/ui/lifecycle/pages/button-counter.ts index 707065aaa..37bdf093a 100644 --- a/tests/app/ui/lifecycle/pages/button-counter.ts +++ b/tests/app/ui/lifecycle/pages/button-counter.ts @@ -5,6 +5,13 @@ export class Button extends button.Button { nativeBackgroundRedraws = 0; backgroundInternalSetNativeCount = 0; fontInternalSetNativeCount = 0; + colorSetNativeCount = 0; + colorPropertyChangeCount = 0; + + constructor() { + super(); + this.style.on("colorChange", () => this.colorPropertyChangeCount++); + } [view.backgroundInternalProperty.setNative](value) { this.backgroundInternalSetNativeCount++; @@ -18,5 +25,9 @@ export class Button extends button.Button { this.nativeBackgroundRedraws++; super._redrawNativeBackground(value); } + [view.colorProperty.setNative](value) { + this.colorSetNativeCount++; + return super[view.colorProperty.setNative](value); + } } Button.prototype.recycleNativeView = "never"; diff --git a/tests/app/ui/lifecycle/pages/page-two.css b/tests/app/ui/lifecycle/pages/page-two.css new file mode 100644 index 000000000..80523328d --- /dev/null +++ b/tests/app/ui/lifecycle/pages/page-two.css @@ -0,0 +1,15 @@ +Button { + color: orange; +} + +.red { + color: red; +} + +.blue { + color: blue; +} + +.nocolor { + background: red; +} \ No newline at end of file diff --git a/tests/app/ui/lifecycle/pages/page-two.xml b/tests/app/ui/lifecycle/pages/page-two.xml new file mode 100644 index 000000000..f85f8754c --- /dev/null +++ b/tests/app/ui/lifecycle/pages/page-two.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/app/ui/segmented-bar/segmented-bar-tests-native.ios.ts b/tests/app/ui/segmented-bar/segmented-bar-tests-native.ios.ts index 6da042466..64fe7a0e8 100644 --- a/tests/app/ui/segmented-bar/segmented-bar-tests-native.ios.ts +++ b/tests/app/ui/segmented-bar/segmented-bar-tests-native.ios.ts @@ -5,12 +5,10 @@ export function getNativeItemsCount(bar: segmentedBarModule.SegmentedBar): numbe } export function checkNativeItemsTextColor(bar: segmentedBarModule.SegmentedBar): boolean { - var isValid = true; - var attrs = (bar.nativeViewProtected).titleTextAttributesForState(UIControlState.Normal); - isValid = bar.color && attrs && attrs.valueForKey(NSForegroundColorAttributeName) === bar.color.ios; - - return isValid; + var nativeViewColor = bar.color && attrs && attrs.valueForKey(NSForegroundColorAttributeName); + var barColor = bar.color.ios; + return barColor.isEqual(nativeViewColor); } export function setNativeSelectedIndex(bar: segmentedBarModule.SegmentedBar, index: number): void { diff --git a/tests/app/ui/styling/style-tests.ts b/tests/app/ui/styling/style-tests.ts index a6c9aa17b..da435c3bf 100644 --- a/tests/app/ui/styling/style-tests.ts +++ b/tests/app/ui/styling/style-tests.ts @@ -67,6 +67,7 @@ export function test_applies_css_changes_to_application_rules_after_page_load() helper.buildUIAndRunTest(label1, function (views: Array) { application.addCss(".applicationChangedLabelAfter { color: blue; }"); label1.className = "applicationChangedLabelAfter"; + console.log("IsLoaded: " + label1.isLoaded); helper.assertViewColor(label1, "#0000FF"); }); } @@ -615,7 +616,7 @@ export function test_setInlineStyle_setsLocalValues() { stack.addChild(testButton); helper.buildUIAndRunTest(stack, function (views: Array) { - (testButton)._applyInlineStyle("color: red;"); + (testButton).style = "color: red;"; helper.assertViewColor(testButton, "#FF0000"); }); } @@ -624,7 +625,7 @@ export function test_setStyle_throws() { const testButton = new buttonModule.Button(); TKUnit.assertThrows(function () { - (testButton).style = "background-color: red;"; + (testButton).style = {}; }, "View.style property is read-only."); } diff --git a/tests/app/ui/styling/value-source-tests.ts b/tests/app/ui/styling/value-source-tests.ts index a00995363..c94522911 100644 --- a/tests/app/ui/styling/value-source-tests.ts +++ b/tests/app/ui/styling/value-source-tests.ts @@ -5,6 +5,19 @@ import * as helper from "../helper"; import * as TKUnit from "../../TKUnit"; import { unsetValue } from "tns-core-modules/ui/core/view"; +export var test_value_Inherited_after_unset = function () { + let page = helper.getCurrentPage(); + page.css = "StackLayout { color: #FF0000; } .blue { color: #0000FF; }"; + let btn = new button.Button(); + let testStack = new stack.StackLayout(); + page.content = testStack; + testStack.addChild(btn); + btn.className = "blue"; + helper.assertViewColor(btn, "#0000FF"); + btn.className = ""; + helper.assertViewColor(btn, "#FF0000"); +} + export var test_value_Inherited_stronger_than_Default = function () { let page = helper.getCurrentPage(); let btn = new button.Button(); diff --git a/tns-core-modules/ui/builder/component-builder/component-builder.ts b/tns-core-modules/ui/builder/component-builder/component-builder.ts index 8a83cfe2f..7985d0f9d 100644 --- a/tns-core-modules/ui/builder/component-builder/component-builder.ts +++ b/tns-core-modules/ui/builder/component-builder/component-builder.ts @@ -125,12 +125,6 @@ const applyComponentCss = profile("applyComponentCss", (instance: View, moduleNa cssApplied = true; } } - - if (!cssApplied) { - // Called only to apply application css. - // If we have page css (through file or cssAttribute) we have appCss applied. - (instance)._refreshCss(); - } } }); @@ -211,13 +205,7 @@ export function setPropertyValue(instance: View, instanceModule: Object, exports instance[propertyName] = exports[propertyValue]; } else { - let attrHandled = false; - if (!attrHandled && instance._applyXmlAttribute) { - attrHandled = instance._applyXmlAttribute(propertyName, propertyValue); - } - if (!attrHandled) { - instance[propertyName] = propertyValue; - } + instance[propertyName] = propertyValue; } } diff --git a/tns-core-modules/ui/core/properties/properties.d.ts b/tns-core-modules/ui/core/properties/properties.d.ts index 3354f6f71..43dda71d2 100644 --- a/tns-core-modules/ui/core/properties/properties.d.ts +++ b/tns-core-modules/ui/core/properties/properties.d.ts @@ -77,6 +77,7 @@ export class CssProperty { public readonly setNative: symbol; public readonly name: string; public readonly cssName: string; + public readonly cssLocalName: string; public readonly defaultValue: U; public register(cls: { prototype: T }): void; public isSet(instance: T): boolean; @@ -92,7 +93,7 @@ export class ShorthandProperty { public readonly name: string; public readonly cssName: string; - public register(cls: { prototype: T }): void; + public register(cls: typeof Style): void; } export class CssAnimationProperty { @@ -100,11 +101,10 @@ export class CssAnimationProperty { public readonly getDefault: symbol; public readonly setNative: symbol; - public readonly key: symbol; public readonly name: string; public readonly cssName: string; - public readonly native: symbol; + public readonly cssLocalName: string; readonly keyframe: string; diff --git a/tns-core-modules/ui/core/properties/properties.ts b/tns-core-modules/ui/core/properties/properties.ts index ab0b94f48..77a9f9227 100644 --- a/tns-core-modules/ui/core/properties/properties.ts +++ b/tns-core-modules/ui/core/properties/properties.ts @@ -646,9 +646,10 @@ export class CssProperty implements definitions.CssProperty< } CssProperty.prototype.isStyleProperty = true; -export class CssAnimationProperty { +export class CssAnimationProperty implements definitions.CssAnimationProperty { public readonly name: string; public readonly cssName: string; + public readonly cssLocalName: string; public readonly getDefault: symbol; public readonly setNative: symbol; @@ -682,7 +683,9 @@ export class CssAnimationProperty { this._valueConverter = options.valueConverter; - const cssName = "css:" + (options.cssName || propertyName); + const cssLocalName = (options.cssName || propertyName); + this.cssLocalName = cssLocalName; + const cssName = "css:" + cssLocalName; this.cssName = cssName; const keyframeName = "keyframe:" + propertyName; @@ -866,8 +869,10 @@ export class InheritedCssProperty extends CssProperty } } + const oldValue: U = key in this ? this[key] : defaultValue; const view = this.view; let value: U; + let unsetNativeValue = false; if (reset) { // If unsetValue - we want to reset this property. let parent = view.parent; @@ -876,9 +881,12 @@ export class InheritedCssProperty extends CssProperty if (style && style[sourceKey] > ValueSource.Default) { value = style[propertyName]; this[sourceKey] = ValueSource.Inherited; + this[key] = value; } else { value = defaultValue; delete this[sourceKey]; + delete this[key]; + unsetNativeValue = true; } } else { this[sourceKey] = valueSource; @@ -887,44 +895,30 @@ export class InheritedCssProperty extends CssProperty } else { value = boxedValue; } + this[key] = value; } - const oldValue: U = key in this ? this[key] : defaultValue; const changed: boolean = equalityComparer ? !equalityComparer(oldValue, value) : oldValue !== value; if (changed) { const view = this.view; - if (reset) { - delete this[key]; - if (valueChanged) { - valueChanged(this, oldValue, value); - } + if (valueChanged) { + valueChanged(this, oldValue, value); + } - if (view[setNative]) { - if (view._suspendNativeUpdatesCount) { - if (view._suspendedUpdates) { - view._suspendedUpdates[propertyName] = property; - } - } else { + if (view[setNative]) { + if (view._suspendNativeUpdatesCount) { + if (view._suspendedUpdates) { + view._suspendedUpdates[propertyName] = property; + } + } else { + if (unsetNativeValue) { if (defaultValueKey in this) { view[setNative](this[defaultValueKey]); delete this[defaultValueKey]; } else { view[setNative](defaultValue); } - } - } - } else { - this[key] = value; - if (valueChanged) { - valueChanged(this, oldValue, value); - } - - if (view[setNative]) { - if (view._suspendNativeUpdatesCount) { - if (view._suspendedUpdates) { - view._suspendedUpdates[propertyName] = property; - } } else { if (!(defaultValueKey in this)) { this[defaultValueKey] = view[getDefault] ? view[getDefault]() : defaultValue; @@ -981,6 +975,7 @@ export class ShorthandProperty implements definitions.Shorth protected readonly cssValueDescriptor: PropertyDescriptor; protected readonly localValueDescriptor: PropertyDescriptor; + protected readonly propertyBagDescriptor: PropertyDescriptor; public readonly sourceKey: symbol; @@ -1025,19 +1020,32 @@ export class ShorthandProperty implements definitions.Shorth set: setLocalValue }; + this.propertyBagDescriptor = { + enumerable: false, + configurable: true, + set(value: string) { + converter(value).forEach(([property, value]) => { + this[property.cssLocalName] = value; + }); + } + } + cssSymbolPropertyMap[key] = this; } - public register(cls: { prototype: T }): void { + public register(cls: typeof Style): void { if (this.registered) { throw new Error(`Property ${this.name} already registered.`); } + this.registered = true; Object.defineProperty(cls.prototype, this.name, this.localValueDescriptor); Object.defineProperty(cls.prototype, this.cssName, this.cssValueDescriptor); if (this.cssLocalName !== this.cssName) { Object.defineProperty(cls.prototype, this.cssLocalName, this.localValueDescriptor); } + + Object.defineProperty(cls.prototype.PropertyBag, this.cssLocalName, this.propertyBagDescriptor); } } diff --git a/tns-core-modules/ui/core/view-base/view-base.d.ts b/tns-core-modules/ui/core/view-base/view-base.d.ts index 18835509f..8c5f7e280 100644 --- a/tns-core-modules/ui/core/view-base/view-base.d.ts +++ b/tns-core-modules/ui/core/view-base/view-base.d.ts @@ -167,11 +167,6 @@ export abstract class ViewBase extends Observable { */ public className: string; - /** - * Gets or sets inline style selectors for this view. - */ - public inlineStyleSelector: SelectorCore; - /** * Gets owner page. This is a read-only property. */ @@ -220,15 +215,22 @@ export abstract class ViewBase extends Observable { _domId: number; _cssState: any /* "ui/styling/style-scope" */; - _setCssState(next: any /* "ui/styling/style-scope" */); - _registerAnimation(animation: KeyframeAnimation); - _unregisterAnimation(animation: KeyframeAnimation); - _cancelAllAnimations(); + /** + * @private + * Notifies each child's css state for change, recursively. + * Either the style scope, className or id properties were changed. + */ + _onCssStateChange(): void; public cssClasses: Set; public cssPseudoClasses: Set; public _goToVisualState(state: string): void; + /** + * This used to be the way to set attribute values in early {N} versions. + * Now attributes are expected to be set as plain properties on the view instances. + * @deprecated + */ public _applyXmlAttribute(attribute, value): boolean; public setInlineStyle(style: string): void; @@ -293,7 +295,7 @@ export abstract class ViewBase extends Observable { /** * @protected * @unstable - * A widget can call this method to discard mathing css pseudo class. + * A widget can call this method to discard matching css pseudo class. */ public deletePseudoClass(name: string): void; @@ -331,6 +333,11 @@ export abstract class ViewBase extends Observable { * @private */ _setupAsRootView(context: any): void; + + /** + * @private + */ + _inheritStyleScope(styleScope: any /* StyleScope */): void; //@endprivate } @@ -348,4 +355,4 @@ export const bindingContextProperty: InheritedProperty; * Converts string into boolean value. * Throws error if value is not 'true' or 'false'. */ -export function booleanConverter(v: string): boolean; \ No newline at end of file +export function booleanConverter(v: string): boolean; diff --git a/tns-core-modules/ui/core/view-base/view-base.ts b/tns-core-modules/ui/core/view-base/view-base.ts index 6953e28f5..4a8a0565d 100644 --- a/tns-core-modules/ui/core/view-base/view-base.ts +++ b/tns-core-modules/ui/core/view-base/view-base.ts @@ -138,9 +138,7 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition private _androidView: Object; private _style: Style; private _isLoaded: boolean; - private _registeredAnimations: Array; private _visualState: string; - private _inlineStyleSelector: SelectorCore; private __nativeView: any; // private _disableNativeViewRecycling: boolean; public domNode: DOMNode; @@ -157,7 +155,7 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition public _domId: number; public _context: any; public _isAddedToNativeVisualTree: boolean; - public _cssState: ssm.CssState; + public _cssState: ssm.CssState = new ssm.CssState(this); public _styleScope: ssm.StyleScope; public _suspendedUpdates: { [propertyName: string]: Property | CssProperty | CssAnimationProperty }; public _suspendNativeUpdatesCount: number; @@ -229,8 +227,12 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition get style(): Style { return this._style; } - set style(value) { - throw new Error("View.style property is read-only."); + set style(inlineStyle: Style /* | string */) { + if (typeof inlineStyle === "string") { + this.setInlineStyle(inlineStyle); + } else { + throw new Error("View.style property is read-only."); + } } get android(): any { @@ -254,13 +256,6 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition this.className = v; } - get inlineStyleSelector(): SelectorCore { - return this._inlineStyleSelector; - } - set inlineStyleSelector(value: SelectorCore) { - this._inlineStyleSelector = value; - } - getViewById(id: string): T { return getViewById(this, id); } @@ -288,6 +283,7 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition @profile public onLoaded() { this._isLoaded = true; + this._cssState.onLoaded(); this._resumeNativeUpdates(); this._loadEachChild(); this._emit("loaded"); @@ -305,6 +301,7 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition this._suspendNativeUpdates(); this._unloadEachChild(); this._isLoaded = false; + this._cssState.onUnloaded(); this._emit("unloaded"); } @@ -336,101 +333,10 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition }); } - @profile - public _applyStyleFromScope() { - const scope = this._styleScope; - if (scope) { - scope.applySelectors(this); - } else { - this._setCssState(null); - } - } - - // TODO: Make sure the state is set to null and this is called on unloaded to clean up change listeners... - @profile - _setCssState(next: ssm.CssState): void { - const previous = this._cssState; - this._cssState = next; - - if (!this._invalidateCssHandler) { - this._invalidateCssHandler = () => { - if (this._invalidateCssHandlerSuspended) { - return; - } - this.applyCssState(); - }; - } - - try { - this._invalidateCssHandlerSuspended = true; - - if (next) { - next.changeMap.forEach((changes, view) => { - if (changes.attributes) { - changes.attributes.forEach(attribute => { - view.addEventListener(attribute + "Change", this._invalidateCssHandler); - }); - } - if (changes.pseudoClasses) { - changes.pseudoClasses.forEach(pseudoClass => { - let eventName = ":" + pseudoClass; - view.addEventListener(":" + pseudoClass, this._invalidateCssHandler); - if (view[eventName]) { - view[eventName](+1); - } - }); - } - }); - } - - if (previous) { - previous.changeMap.forEach((changes, view) => { - if (changes.attributes) { - changes.attributes.forEach(attribute => { - view.removeEventListener("onPropertyChanged:" + attribute, this._invalidateCssHandler); - }); - } - if (changes.pseudoClasses) { - changes.pseudoClasses.forEach(pseudoClass => { - let eventName = ":" + pseudoClass; - view.removeEventListener(eventName, this._invalidateCssHandler); - if (view[eventName]) { - view[eventName](-1); - } - }); - } - }); - } - - } finally { - this._invalidateCssHandlerSuspended = false; - } - - this.applyCssState(); - } - private notifyPseudoClassChanged(pseudoClass: string): void { this.notify({ eventName: ":" + pseudoClass, object: this }); } - /** - * Notify that some attributes or pseudo classes that may affect the current CssState had changed. - */ - private _invalidateCssHandler; - private _invalidateCssHandlerSuspended: boolean; - - @profile - private applyCssState(): void { - this._batchUpdate(() => { - if (!this._cssState) { - this._cancelAllAnimations(); - resetCSSProperties(this.style); - return; - } - this._cssState.apply(); - }); - } - private pseudoClassAliases = { 'highlighted': [ 'active', @@ -474,19 +380,6 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition } } - @profile - private _applyInlineStyle(inlineStyle) { - if (typeof inlineStyle === "string") { - try { - // this.style._beginUpdate(); - ensureStyleScopeModule(); - styleScopeModule.applyInlineStyle(this, inlineStyle); - } finally { - // this.style._endUpdate(); - } - } - } - private bindingContextChanged(data: PropertyChangeData): void { this.bindings.get("bindingContext").bind(data.value); } @@ -584,24 +477,9 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition } } - @profile - private _setStyleScope(scope: ssm.StyleScope): void { - this._styleScope = scope; - this._applyStyleFromScope(); - this.eachChild((v) => { - v._setStyleScope(scope); - return true; - }); - } - public _addViewCore(view: ViewBase, atIndex?: number) { propagateInheritableProperties(this, view); - - const styleScope = this._styleScope; - if (styleScope) { - view._setStyleScope(styleScope); - } - + view._inheritStyleScope(this._styleScope); propagateInheritableCssProperties(this.style, view.style); if (this._context) { @@ -614,8 +492,8 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition } /** - * Core logic for removing a child view from this instance. Used by the framework to handle lifecycle events more centralized. Do not use outside the UI Stack implementation. - */ + * Core logic for removing a child view from this instance. Used by the framework to handle lifecycle events more centralized. Do not use outside the UI Stack implementation. + */ public _removeView(view: ViewBase) { if (traceEnabled()) { traceWrite(`${this}._removeView(${view})`, traceCategories.ViewHierarchy); @@ -638,17 +516,10 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition * Method is intended to be overridden by inheritors and used as "protected" */ public _removeViewCore(view: ViewBase) { - // TODO: Discuss this. - if (this._styleScope === view._styleScope) { - view._setStyleScope(null); - } - if (view.isLoaded) { view.onUnloaded(); } - // view.unsetInheritedProperties(); - if (view._context) { view._tearDownUI(); } @@ -663,10 +534,7 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition } public initNativeView(): void { - // No initNativeView(this)? - if (this._cssState) { - this._cssState.playPendingKeyframeAnimations(); - } + // } public resetNativeView(): void { @@ -688,9 +556,9 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition // } // } - if (this._cssState) { - this._cancelAllAnimations(); - } + // if (this._cssState) { + // this._cancelAllAnimations(); + // } } _setupAsRootView(context: any): void { @@ -895,12 +763,12 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition this.addPseudoClass(state); } - public _applyXmlAttribute(attribute, value): boolean { - if (attribute === "style") { - this._applyInlineStyle(value); - return true; - } - + /** + * This used to be the way to set attribute values in early {N} versions. + * Now attributes are expected to be set as plain properties on the view instances. + * @deprecated + */ + public _applyXmlAttribute(): boolean { return false; } @@ -909,7 +777,8 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition throw new Error("Parameter should be valid CSS string!"); } - this._applyInlineStyle(style); + ensureStyleScopeModule(); + styleScopeModule.applyInlineStyle(this, style); } public _parentChanged(oldParent: ViewBase): void { @@ -932,30 +801,6 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition initNativeView(this); } - public _registerAnimation(animation: KeyframeAnimation) { - if (this._registeredAnimations === undefined) { - this._registeredAnimations = new Array(); - } - this._registeredAnimations.push(animation); - } - - public _unregisterAnimation(animation: KeyframeAnimation) { - if (this._registeredAnimations) { - let index = this._registeredAnimations.indexOf(animation); - if (index >= 0) { - this._registeredAnimations.splice(index, 1); - } - } - } - - public _cancelAllAnimations() { - if (this._registeredAnimations) { - for (let animation of this._registeredAnimations) { - animation.cancel(); - } - } - } - public toString(): string { let str = this.typeName; if (this.id) { @@ -970,6 +815,25 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition return str; } + + _onCssStateChange(): void { + this._cssState.onChange(); + eachDescendant(this, (child: ViewBase) => { + child._cssState.onChange(); + return true; + }); + } + + _inheritStyleScope(styleScope: ssm.StyleScope): void { + if (this._styleScope !== styleScope) { + this._styleScope = styleScope; + this._onCssStateChange(); + this.eachChild(child => { + child._inheritStyleScope(styleScope); + return true + }); + } + } } ViewBase.prototype.isCollapsed = false; @@ -1019,20 +883,12 @@ export const classNameProperty = new Property({ if (typeof newValue === "string") { newValue.split(" ").forEach(c => classes.add(c)); } - resetStyles(view); + view._onCssStateChange(); } }); classNameProperty.register(ViewBase); -function resetStyles(view: ViewBase): void { - view._applyStyleFromScope(); - view.eachChild((child) => { - resetStyles(child); - return true; - }); -} - -export const idProperty = new Property({ name: "id", valueChanged: (view, oldValue, newValue) => resetStyles(view) }); +export const idProperty = new Property({ name: "id", valueChanged: (view, oldValue, newValue) => view._onCssStateChange() }); idProperty.register(ViewBase); export function booleanConverter(v: string): boolean { diff --git a/tns-core-modules/ui/core/view/view.android.ts b/tns-core-modules/ui/core/view/view.android.ts index 1becfaa01..42d9418d5 100644 --- a/tns-core-modules/ui/core/view/view.android.ts +++ b/tns-core-modules/ui/core/view/view.android.ts @@ -100,7 +100,6 @@ export class View extends ViewCommon { this.nativeViewProtected.setClickable(this._isClickable); } - this._cancelAllAnimations(); super.onUnloaded(); } diff --git a/tns-core-modules/ui/core/view/view.d.ts b/tns-core-modules/ui/core/view/view.d.ts index 0645194d3..5b6c848fe 100644 --- a/tns-core-modules/ui/core/view/view.d.ts +++ b/tns-core-modules/ui/core/view/view.d.ts @@ -67,7 +67,7 @@ export interface Size { * This class is the base class for all UI components. * A View occupies a rectangular area on the screen and is responsible for drawing and layouting of all UI components within. */ -export abstract class View extends ViewBase implements ApplyXmlAttributes { +export abstract class View extends ViewBase { /** * Gets the android-specific native instance that lies behind this proxy. Will be available if running on an Android platform. */ @@ -83,8 +83,6 @@ export abstract class View extends ViewBase implements ApplyXmlAttributes { */ bindingContext: any; - //----------Style property shortcuts---------- - /** * Gets or sets the border color of the view. */ @@ -413,12 +411,6 @@ export abstract class View extends ViewBase implements ApplyXmlAttributes { */ public focus(): boolean; - /** - * Sets in-line CSS string as style. - * @param style - In-line CSS string. - */ - public setInlineStyle(style: string): void; - public getGestureObservers(type: GestureTypes): Array; /** @@ -490,7 +482,6 @@ export abstract class View extends ViewBase implements ApplyXmlAttributes { _eachLayoutView(callback: (View) => void): void; - public _applyXmlAttribute(attribute: string, value: any): boolean; public eachChildView(callback: (view: View) => boolean): void; //@private @@ -647,19 +638,6 @@ export interface AddChildFromBuilder { _addChildFromBuilder(name: string, value: any): void; } -/** - * Defines an interface used to create a member of a class from string representation (used in xml declaration). - */ -export interface ApplyXmlAttributes { - /** - * Called for every attribute in xml declaration. <... fontWeight="bold" ../> - * @param attributeName - the name of the attribute (fontAttributes) - * @param attrValue - the value of the attribute (bold) - * Should return true if this attribute is handled and there is no need default handler to process it. - */ - _applyXmlAttribute(attributeName: string, attrValue: any): boolean; -} - export const automationTextProperty: Property; export const originXProperty: Property; export const originYProperty: Property; diff --git a/tns-core-modules/ui/dialogs/dialogs-common.ts b/tns-core-modules/ui/dialogs/dialogs-common.ts index b1eb372fe..b21e8f4d6 100644 --- a/tns-core-modules/ui/dialogs/dialogs-common.ts +++ b/tns-core-modules/ui/dialogs/dialogs-common.ts @@ -49,9 +49,9 @@ export function getCurrentPage(): Page { function applySelectors(view: View) { let currentPage = getCurrentPage(); if (currentPage) { - let styleScope = currentPage._getStyleScope(); + let styleScope = currentPage._styleScope; if (styleScope) { - styleScope.applySelectors(view); + styleScope.matchSelectors(view); } } } diff --git a/tns-core-modules/ui/frame/frame-common.ts b/tns-core-modules/ui/frame/frame-common.ts index 847a3545f..8795beb0f 100644 --- a/tns-core-modules/ui/frame/frame-common.ts +++ b/tns-core-modules/ui/frame/frame-common.ts @@ -89,7 +89,6 @@ const entryCreatePage = profile("entry.create", (entry: NavigationEntry): Page = throw new Error("Failed to create Page with entry.create() function."); } - page._refreshCss(); return page; }); diff --git a/tns-core-modules/ui/layouts/grid-layout/grid-layout-common.ts b/tns-core-modules/ui/layouts/grid-layout/grid-layout-common.ts index 0db2ba0c3..21255d501 100644 --- a/tns-core-modules/ui/layouts/grid-layout/grid-layout-common.ts +++ b/tns-core-modules/ui/layouts/grid-layout/grid-layout-common.ts @@ -281,28 +281,15 @@ export class GridLayoutBase extends LayoutBase implements GridLayoutDefinition { this.requestLayout(); } - _applyXmlAttribute(attributeName: string, attributeValue: any): boolean { - if (attributeName === "columns") { - this._setColumns(attributeValue); - return true; - } - else if (attributeName === "rows") { - this._setRows(attributeValue); - return true; - } - - return super._applyXmlAttribute(attributeName, attributeValue); - } - - private _setColumns(value: string) { - this.removeColumns(); - parseAndAddItemSpecs(value, (spec: ItemSpec) => this.addColumn(spec)); - } - - private _setRows(value: string) { + set rows(value: string) { this.removeRows(); parseAndAddItemSpecs(value, (spec: ItemSpec) => this.addRow(spec)); } + + set columns(value: string) { + this.removeColumns(); + parseAndAddItemSpecs(value, (spec: ItemSpec) => this.addColumn(spec)); + } } GridLayoutBase.prototype.recycleNativeView = "auto"; diff --git a/tns-core-modules/ui/page/page-common.ts b/tns-core-modules/ui/page/page-common.ts index 510ff4590..91629ab16 100644 --- a/tns-core-modules/ui/page/page-common.ts +++ b/tns-core-modules/ui/page/page-common.ts @@ -22,14 +22,12 @@ export class PageBase extends ContentView implements PageDefinition { public static showingModallyEvent = "showingModally"; protected _closeModalCallback: Function; - private _modalContext: any; + private _modalContext: any; private _navigationContext: any; private _actionBar: ActionBar; - private _cssAppliedVersion: number; - public _styleScope: StyleScope; // same as in ViewBase, but strongly typed public _modal: PageBase; public _fragmentTag: string; @@ -51,8 +49,7 @@ export class PageBase extends ContentView implements PageDefinition { } set css(value: string) { this._styleScope.css = value; - this._cssFiles = {}; - this._refreshCss(); + this._onCssStateChange(); } get actionBar(): ActionBar { @@ -94,59 +91,14 @@ export class PageBase extends ContentView implements PageDefinition { return this; } - @profile - public onLoaded(): void { - this._refreshCss(); - super.onLoaded(); - } - - public onUnloaded() { - const styleScope = this._styleScope; - super.onUnloaded(); - this._styleScope = styleScope; - } - public addCss(cssString: string): void { - this._addCssInternal(cssString); + this._styleScope.addCss(cssString); + this._onCssStateChange(); } - private _addCssInternal(cssString: string, cssFileName?: string): void { - this._styleScope.addCss(cssString, cssFileName); - this._refreshCss(); - } - - private _cssFiles = {}; public addCssFile(cssFileName: string) { - if (cssFileName.indexOf("~/") === 0) { - cssFileName = path.join(knownFolders.currentApp().path, cssFileName.replace("~/", "")); - } - if (!this._cssFiles[cssFileName]) { - if (File.exists(cssFileName)) { - const file = File.fromPath(cssFileName); - const text = file.readTextSync(); - if (text) { - this._addCssInternal(text, cssFileName); - this._cssFiles[cssFileName] = true; - } - } - } - } - - // Used in component-builder.ts - public _refreshCss(): void { - const scopeVersion = this._styleScope.ensureSelectors(); - if (scopeVersion !== this._cssAppliedVersion) { - const styleScope = this._styleScope; - this._resetCssValues(); - const checkSelectors = (view: View): boolean => { - styleScope.applySelectors(view); - return true; - }; - - checkSelectors(this); - eachDescendant(this, checkSelectors); - this._cssAppliedVersion = scopeVersion; - } + this._styleScope.addCssFile(cssFileName); + this._onCssStateChange(); } public getKeyframeAnimationWithName(animationName: string): KeyframeAnimationInfo { @@ -275,10 +227,6 @@ export class PageBase extends ContentView implements PageDefinition { this.notify(args); } - public _getStyleScope(): StyleScope { - return this._styleScope; - } - public eachChildView(callback: (child: View) => boolean) { super.eachChildView(callback); callback(this.actionBar); @@ -288,17 +236,8 @@ export class PageBase extends ContentView implements PageDefinition { return (this.content ? 1 : 0) + (this.actionBar ? 1 : 0); } - private _resetCssValues() { - const resetCssValuesFunc = (view: View): boolean => { - view._batchUpdate(() => { - view._cancelAllAnimations(); - resetCSSProperties(view.style); - }); - return true; - }; - - resetCssValuesFunc(this); - eachDescendant(this, resetCssValuesFunc); + _inheritStyleScope(styleScope: StyleScope): void { + // The Page have its own scope. } } diff --git a/tns-core-modules/ui/page/page.d.ts b/tns-core-modules/ui/page/page.d.ts index 4378376e4..5b7abc8ac 100644 --- a/tns-core-modules/ui/page/page.d.ts +++ b/tns-core-modules/ui/page/page.d.ts @@ -254,15 +254,6 @@ export class Page extends ContentView { * @param isBackNavigation - True if the Page is being navigated from using the Frame.goBack() method, false otherwise. */ public onNavigatedFrom(isBackNavigation: boolean): void; - - /** - * @private - */ - _refreshCss(): void; - /** - * @private - */ - _getStyleScope(): styleScope.StyleScope; //@endprivate } diff --git a/tns-core-modules/ui/styling/style-scope.ts b/tns-core-modules/ui/styling/style-scope.ts index a5839fe37..adf61d1d6 100644 --- a/tns-core-modules/ui/styling/style-scope.ts +++ b/tns-core-modules/ui/styling/style-scope.ts @@ -1,7 +1,7 @@ import { Keyframes } from "../animation/keyframe-animation"; import { ViewBase } from "../core/view-base"; import { View } from "../core/view"; -import { resetCSSProperties } from "../core/properties"; +import { unsetValue } from "../core/properties"; import { SyntaxTree, Keyframes as KeyframesDefinition, @@ -56,9 +56,101 @@ const applicationKeyframes: any = {}; const animationsSymbol: symbol = Symbol("animations"); const pattern: RegExp = /('|")(.*?)\1/; +class CSSSource { + private _selectors: RuleSet[] = []; + private _ast: SyntaxTree; + + private static cssFilesCache: { [path: string]: CSSSource } = {}; + + private constructor(private _url: string, private _file: string, private _keyframes: KeyframesMap, private _source?: string) { + if (this._file && !this._source) { + this.load(); + } + this.parse(); + } + + public static fromFile(url: string, keyframes: KeyframesMap): CSSSource { + const app = knownFolders.currentApp().path; + const file = resolveFileNameFromUrl(url, app, File.exists); + return new CSSSource(url, file, keyframes, undefined); + } + + public static fromSource(source: string, keyframes: KeyframesMap, url?: string): CSSSource { + return new CSSSource(url, undefined, keyframes, source); + } + + get selectors(): RuleSet[] { return this._selectors; } + get source(): string { return this._source; } + + @profile + private load(): void { + const file = File.fromPath(this._file); + this._source = file.readTextSync(); + } + + @profile + private parse(): void { + if (this._source) { + try { + this._ast = this._source ? parseCss(this._source, { source: this._file }) : null; + // TODO: Don't merge arrays, instead chain the css files. + if (this._ast) { + this._selectors = [ + ...this.createSelectorsFromImports(), + ...this.createSelectorsFromSyntaxTree() + ]; + } + } catch (e) { + traceWrite("Css styling failed: " + e, traceCategories.Error, traceMessageType.error); + } + } else { + this._selectors = []; + } + } + + private createSelectorsFromImports(): RuleSet[] { + let selectors: RuleSet[] = []; + const imports = this._ast["stylesheet"]["rules"].filter(r => r.type === "import"); + for (let i = 0; i < imports.length; i++) { + const importItem = imports[i]["import"]; + + const match = importItem && (importItem).match(pattern); + const url = match && match[2]; + + if (url !== null && url !== undefined) { + const cssFile = CSSSource.fromFile(url, this._keyframes); + selectors = selectors.concat(cssFile.selectors); + } + } + + return selectors; + } + + private createSelectorsFromSyntaxTree(): RuleSet[] { + const nodes = this._ast.stylesheet.rules; + (nodes.filter(isKeyframe)).forEach(node => this._keyframes[node.name] = node); + + const rulesets = fromAstNodes(nodes); + if (rulesets && rulesets.length) { + ensureCssAnimationParserModule(); + + rulesets.forEach(rule => { + rule[animationsSymbol] = cssAnimationParserModule.CssAnimationParser + .keyframeAnimationsFromCSSDeclarations(rule.declarations); + }); + } + + return rulesets; + } + + toString(): string { + return this._file || this._url || "(in-memory)"; + } +} + const onCssChanged = profile('"style-scope".onCssChanged', (args: application.CssChangedEventData) => { if (args.cssText) { - const parsed = createSelectorsFromCss(args.cssText, args.cssFile, applicationKeyframes); + const parsed = CSSSource.fromSource(args.cssText, applicationKeyframes, args.cssFile).selectors; if (parsed) { applicationAdditionalSelectors.push.apply(applicationAdditionalSelectors, parsed); mergeCssSelectors(); @@ -72,22 +164,15 @@ function onLiveSync(args: application.CssChangedEventData): void { loadCss(application.getCssFileName()); } -const loadCss = profile(`"style-scope".loadCss`, (cssFile?: string) => { +const loadCss = profile(`"style-scope".loadCss`, (cssFile: string) => { if (!cssFile) { return undefined; } - let result: RuleSet[]; - - const cssFileName = path.join(knownFolders.currentApp().path, cssFile); - if (File.exists(cssFileName)) { - const file = File.fromPath(cssFileName); - const applicationCss = file.readTextSync(); - if (applicationCss) { - result = createSelectorsFromCss(applicationCss, cssFileName, applicationKeyframes); - applicationSelectors = result; - mergeCssSelectors(); - } + const result = CSSSource.fromFile(cssFile, applicationKeyframes).selectors; + if (result.length > 0) { + applicationSelectors = result; + mergeCssSelectors(); } }); @@ -106,69 +191,186 @@ if (application.hasLaunched()) { } export class CssState { - private _pendingKeyframeAnimations: SelectorCore[]; + static emptyChangeMap: Readonly> = Object.freeze(new Map()); + static emptyPropertyBag: Readonly<{}> = Object.freeze({}); + static emptyAnimationArray: ReadonlyArray = Object.freeze([]); + static emptyMatch: Readonly> = { selectors: [], changeMap: new Map() }; - constructor(private view: ViewBase, private match: SelectorsMatch) { + _onDynamicStateChangeHandler: () => void; + _appliedChangeMap: Readonly>; + _appliedPropertyValues: Readonly<{}>; + _appliedAnimations: ReadonlyArray; + + _match: SelectorsMatch; + _matchInvalid: boolean; + + constructor(private view: ViewBase) { + this._onDynamicStateChangeHandler = () => this.updateDynamicState(); } - public get changeMap(): ChangeMap { - return this.match.changeMap; - } - - public apply(): void { - this.view._cancelAllAnimations(); - resetCSSProperties(this.view.style); - - let matchingSelectors = this.match.selectors.filter(sel => sel.dynamic ? sel.match(this.view) : true); - if (this.view.inlineStyleSelector) { - matchingSelectors.push(this.view.inlineStyleSelector); - } - - matchingSelectors.forEach(s => this.applyDescriptors(s.ruleset)); - this._pendingKeyframeAnimations = matchingSelectors; - this.playPendingKeyframeAnimations(); - } - - public playPendingKeyframeAnimations() { - if (this._pendingKeyframeAnimations && this.view.nativeViewProtected) { - this._pendingKeyframeAnimations.forEach(s => this.playKeyframeAnimationsFromRuleSet(s.ruleset)); - this._pendingKeyframeAnimations = null; + /** + * Called when a change had occurred that may invalidate the statically matching selectors (class, id, ancestor selectors). + * As a result, at some point in time, the selectors matched have to be requerried from the style scope and applied to the view. + */ + public onChange(): void { + if (this.view.isLoaded) { + this.unsubscribeFromDynamicUpdates(); + this.updateMatch(); + this.subscribeForDynamicUpdates(); + this.updateDynamicState(); + } else { + this._matchInvalid = true; } } - private applyDescriptors(ruleset: RuleSet): void { - let style = this.view.style; - ruleset.declarations.forEach(d => { - try { - // Use the "css:" prefixed name, so that CSS value source is set. - let cssPropName = `css:${d.property}`; - if (cssPropName in style) { - style[cssPropName] = d.value; - } else { - this.view[d.property] = d.value; + public onLoaded(): void { + if (this._matchInvalid) { + this.updateMatch(); + } + this.subscribeForDynamicUpdates(); + this.updateDynamicState(); + } + + public onUnloaded(): void { + this.unsubscribeFromDynamicUpdates(); + } + + @profile + private updateMatch() { + this._match = this.view._styleScope ? this.view._styleScope.matchSelectors(this.view) : CssState.emptyMatch; + this._matchInvalid = false; + } + + @profile + private updateDynamicState(): void { + const matchingSelectors = this._match.selectors.filter(sel => sel.dynamic ? sel.match(this.view) : true); + + this.stopKeyframeAnimations(); + this.setPropertyValues(matchingSelectors); + this.playKeyframeAnimations(matchingSelectors); + } + + private playKeyframeAnimations(matchingSelectors: SelectorCore[]): void { + const animations: kam.KeyframeAnimation[] = []; + + matchingSelectors.forEach(selector => { + let ruleAnimations: kam.KeyframeAnimationInfo[] = selector.ruleset[animationsSymbol]; + if (ruleAnimations) { + ensureKeyframeAnimationModule(); + for (let animationInfo of ruleAnimations) { + let animation = keyframeAnimationModule.KeyframeAnimation.keyframeAnimationFromInfo(animationInfo); + if (animation) { + animations.push(animation); + } } - } catch (e) { - traceWrite(`Failed to apply property [${d.property}] with value [${d.value}] to ${this.view}. ${e}`, traceCategories.Error, traceMessageType.error); } }); + + animations.forEach(animation => animation.play(this.view)); + Object.freeze(animations); + this._appliedAnimations = animations; } - private playKeyframeAnimationsFromRuleSet(ruleset: RuleSet): void { - let ruleAnimations: kam.KeyframeAnimationInfo[] = ruleset[animationsSymbol]; - if (ruleAnimations) { - ensureKeyframeAnimationModule(); - for (let animationInfo of ruleAnimations) { - let animation = keyframeAnimationModule.KeyframeAnimation.keyframeAnimationFromInfo(animationInfo); - if (animation) { - this.view._registerAnimation(animation); - animation.play(this.view) - .then(() => { this.view._unregisterAnimation(animation); }) - .catch((e) => { this.view._unregisterAnimation(animation); }); + private stopKeyframeAnimations(): void { + this._appliedAnimations + .filter(animation => animation.isPlaying) + .forEach(animation => animation.cancel()); + this._appliedAnimations = CssState.emptyAnimationArray; + } + + /** + * Calculate the difference between the previously applied property values, + * and the new set of property values that have to be applied for the provided selectors. + * Apply the values and ensure each property setter is called at most once to avoid excessive change notifications. + * @param matchingSelectors + */ + private setPropertyValues(matchingSelectors: SelectorCore[]): void { + const newPropertyValues = new this.view.style.PropertyBag(); + matchingSelectors.forEach(selector => + selector.ruleset.declarations.forEach(declaration => + newPropertyValues[declaration.property] = declaration.value)); + Object.freeze(newPropertyValues); + + this.view._batchUpdate(() => { + const oldProperties = this._appliedPropertyValues; + for(const key in oldProperties) { + if (!(key in newPropertyValues)) { + if (key in this.view.style) { + this.view.style[`css:${key}`] = unsetValue; + } else { + // TRICKY: How do we unset local value? + } } } - } + for(const property in newPropertyValues) { + if (oldProperties && property in oldProperties && oldProperties[property] === newPropertyValues[property]) { + continue; + } + const value = newPropertyValues[property]; + try { + if (property in this.view.style) { + this.view.style[`css:${property}`] = value; + } else { + this.view[property] = value; + } + } catch (e) { + traceWrite(`Failed to apply property [${property}] with value [${value}] to ${this.view}. ${e}`, traceCategories.Error, traceMessageType.error); + } + } + }); + + this._appliedPropertyValues = newPropertyValues; + } + + private subscribeForDynamicUpdates(): void { + const changeMap = this._match.changeMap; + changeMap.forEach((changes, view) => { + if (changes.attributes) { + changes.attributes.forEach(attribute => { + view.addEventListener(attribute + "Change", this._onDynamicStateChangeHandler); + }); + } + if (changes.pseudoClasses) { + changes.pseudoClasses.forEach(pseudoClass => { + let eventName = ":" + pseudoClass; + view.addEventListener(":" + pseudoClass, this._onDynamicStateChangeHandler); + if (view[eventName]) { + view[eventName](+1); + } + }); + } + }); + this._appliedChangeMap = changeMap; + } + + private unsubscribeFromDynamicUpdates(): void { + this._appliedChangeMap.forEach((changes, view) => { + if (changes.attributes) { + changes.attributes.forEach(attribute => { + view.removeEventListener("onPropertyChanged:" + attribute, this._onDynamicStateChangeHandler); + }); + } + if (changes.pseudoClasses) { + changes.pseudoClasses.forEach(pseudoClass => { + let eventName = ":" + pseudoClass; + view.removeEventListener(eventName, this._onDynamicStateChangeHandler); + if (view[eventName]) { + view[eventName](-1); + } + }); + } + }); + this._appliedChangeMap = CssState.emptyChangeMap; + } + + toString(): string { + return `${this.view}._cssState`; } } +CssState.prototype._appliedChangeMap = CssState.emptyChangeMap; +CssState.prototype._appliedPropertyValues = CssState.emptyPropertyBag; +CssState.prototype._appliedAnimations = CssState.emptyAnimationArray; +CssState.prototype._matchInvalid = true; export class StyleScope { @@ -200,25 +402,31 @@ export class StyleScope { this.appendCss(cssString, cssFileName) } + public addCssFile(cssFileName: string): void { + this.appendCss(null, cssFileName); + } + @profile private setCss(cssString: string, cssFileName?): void { this._css = cssString; this._reset(); - this._localCssSelectors = createSelectorsFromCss(this._css, cssFileName, this._keyframes); + + const cssFile = CSSSource.fromSource(cssString, this._keyframes, cssFileName); + this._localCssSelectors = cssFile.selectors; this._localCssSelectorVersion++; this.ensureSelectors(); } @profile private appendCss(cssString: string, cssFileName?): void { - if (!cssString) { + if (!cssString && !cssFileName) { return; } - this._css = this._css + cssString; this._reset(); - let parsedCssSelectors = createSelectorsFromCss(cssString, cssFileName, this._keyframes); - this._localCssSelectors.push.apply(this._localCssSelectors, parsedCssSelectors); + let parsedCssSelectors = cssString ? CSSSource.fromSource(cssString, this._keyframes, cssFileName) : CSSSource.fromFile(cssFileName, this._keyframes); + this._css = this._css + parsedCssSelectors.source; + this._localCssSelectors.push.apply(this._localCssSelectors, parsedCssSelectors.selectors); this._localCssSelectorVersion++; this.ensureSelectors(); } @@ -267,13 +475,10 @@ export class StyleScope { } } - public applySelectors(view: ViewBase): void { + @profile + public matchSelectors(view: ViewBase): SelectorsMatch { this.ensureSelectors(); - - let state = this._selectors.query(view); - - let nextState = new CssState(view, state); - view._setCssState(nextState); + return this._selectors.query(view); } public query(node: Node): SelectorCore[] { @@ -314,66 +519,7 @@ export class StyleScope { } } -function createSelectorsFromCss(css: string, cssFileName: string, keyframes: Map): RuleSet[] { - try { - const pageCssSyntaxTree = css ? parseCss(css, { source: cssFileName }) : null; - let pageCssSelectors: RuleSet[] = []; - if (pageCssSyntaxTree) { - pageCssSelectors = pageCssSelectors.concat(createSelectorsFromImports(pageCssSyntaxTree, keyframes)); - pageCssSelectors = pageCssSelectors.concat(createSelectorsFromSyntaxTree(pageCssSyntaxTree, keyframes)); - } - return pageCssSelectors; - } catch (e) { - traceWrite("Css styling failed: " + e, traceCategories.Error, traceMessageType.error); - } -} - -function createSelectorsFromImports(tree: SyntaxTree, keyframes: Map): RuleSet[] { - let selectors: RuleSet[] = []; - - if (tree !== null && tree !== undefined) { - const imports = tree["stylesheet"]["rules"].filter(r => r.type === "import"); - - for (let i = 0; i < imports.length; i++) { - const importItem = imports[i]["import"]; - - const match = importItem && (importItem).match(pattern); - const url = match && match[2]; - - if (url !== null && url !== undefined) { - const appDirectory = knownFolders.currentApp().path; - const fileName = resolveFileNameFromUrl(url, appDirectory, File.exists); - - if (fileName !== null) { - const file = File.fromPath(fileName); - const text = file.readTextSync(); - if (text) { - selectors = selectors.concat(createSelectorsFromCss(text, fileName, keyframes)); - } - } - } - } - } - - return selectors; -} - -function createSelectorsFromSyntaxTree(ast: SyntaxTree, keyframes: Map): RuleSet[] { - const nodes = ast.stylesheet.rules; - (nodes.filter(isKeyframe)).forEach(node => keyframes[node.name] = node); - - const rulesets = fromAstNodes(nodes); - if (rulesets && rulesets.length) { - ensureCssAnimationParserModule(); - - rulesets.forEach(rule => { - rule[animationsSymbol] = cssAnimationParserModule.CssAnimationParser - .keyframeAnimationsFromCSSDeclarations(rule.declarations); - }); - } - - return rulesets; -} +type KeyframesMap = Map; export function resolveFileNameFromUrl(url: string, appDirectory: string, fileExists: (name: string) => boolean): string { let fileName: string = typeof url === "string" ? url.trim() : ""; @@ -382,22 +528,25 @@ export function resolveFileNameFromUrl(url: string, appDirectory: string, fileEx fileName = fileName.replace("~/", ""); } - let local = path.join(appDirectory, fileName); - if (fileExists(local)) { - return local; + const isAbsolutePath = fileName.indexOf("/") === 0; + const absolutePath = isAbsolutePath ? fileName : path.join(appDirectory, fileName); + if (fileExists(absolutePath)) { + return absolutePath; } - let external = path.join(appDirectory, "tns_modules", fileName); - if (fileExists(external)) { - return external; + if (!isAbsolutePath) { + const external = path.join(appDirectory, "tns_modules", fileName); + if (fileExists(external)) { + return external; + } } return null; } -export function applyInlineStyle(view: ViewBase, styleStr: string) { +export const applyInlineStyle = profile(function applyInlineStyle(view: ViewBase, styleStr: string) { let localStyle = `local { ${styleStr} }`; - let inlineRuleSet = createSelectorsFromCss(localStyle, null, new Map()); + let inlineRuleSet = CSSSource.fromSource(localStyle, new Map()).selectors; const style = view.style; inlineRuleSet[0].declarations.forEach(d => { @@ -413,7 +562,7 @@ export function applyInlineStyle(view: ViewBase, styleStr: string) { traceWrite(`Failed to apply property [${d.property}] with value [${d.value}] to ${view}. ${e}`, traceCategories.Error, traceMessageType.error); } }); -} +}); function isKeyframe(node: CssNode): node is KeyframesDefinition { return node.type === "keyframes"; diff --git a/tns-core-modules/ui/styling/style/style.d.ts b/tns-core-modules/ui/styling/style/style.d.ts index 6886b3de1..65b101e93 100644 --- a/tns-core-modules/ui/styling/style/style.d.ts +++ b/tns-core-modules/ui/styling/style/style.d.ts @@ -48,7 +48,6 @@ export interface CommonLayoutParams { } export class Style extends Observable { - public fontInternal: Font; public backgroundInternal: Background; @@ -149,4 +148,20 @@ export class Style extends Observable { public flexShrink: FlexShrink; public flexWrapBefore: FlexWrapBefore; public alignSelf: AlignSelf; + + /** + * The property bag is a simple class that is paired with the Style class. + * Setting regular css properties on the PropertyBag should simply preserve their values. + * Setting shorthand css properties on the PropertyBag should decompose the provided value, and set each of the shorthand composite properties. + * The shorthand properties are defined as non-enumerable so it should be safe to for-in the keys that are set in the bag. + */ + public readonly PropertyBag: PropertyBagClass; } + +interface PropertyBagClass { + new(): PropertyBag; + prototype: PropertyBag; +} +interface PropertyBag { + [property: string]: string; +} \ No newline at end of file diff --git a/tns-core-modules/ui/styling/style/style.ts b/tns-core-modules/ui/styling/style/style.ts index 96f8f517a..5bd84b7f6 100644 --- a/tns-core-modules/ui/styling/style/style.ts +++ b/tns-core-modules/ui/styling/style/style.ts @@ -118,4 +118,7 @@ export class Style extends Observable implements StyleDefinition { public flexShrink: FlexShrink; public flexWrapBefore: FlexWrapBefore; public alignSelf: AlignSelf; -} \ No newline at end of file + + public PropertyBag: { new(): { [property: string]: string }, prototype: { [property: string]: string } }; +} +Style.prototype.PropertyBag = class { [property: string]: string; } \ No newline at end of file diff --git a/tsconfig.shared.json b/tsconfig.shared.json index ba55cc870..796ec2bab 100644 --- a/tsconfig.shared.json +++ b/tsconfig.shared.json @@ -10,7 +10,7 @@ "removeComments": true, "experimentalDecorators": true, "diagnostics": true, - "sourceMap": true, + "inlineSourceMap": true, "jsx": "react", "reactNamespace": "UIBuilder", "lib": [