diff --git a/apps/automated/src/ui/button/button-tests.ts b/apps/automated/src/ui/button/button-tests.ts index cc3dfd7036..6161b4feaf 100644 --- a/apps/automated/src/ui/button/button-tests.ts +++ b/apps/automated/src/ui/button/button-tests.ts @@ -274,7 +274,7 @@ export var test_StateHighlighted_also_fires_pressedState = function () { helper.waitUntilLayoutReady(view); - view._goToVisualState('highlighted'); + view._addVisualState('highlighted'); var actualResult = buttonTestsNative.getNativeBackgroundColor(view); TKUnit.assert(actualResult.hex === expectedNormalizedColor, 'Actual: ' + actualResult.hex + '; Expected: ' + expectedNormalizedColor); @@ -291,7 +291,7 @@ export var test_StateHighlighted_also_fires_activeState = function () { helper.waitUntilLayoutReady(view); - view._goToVisualState('highlighted'); + view._addVisualState('highlighted'); var actualResult = buttonTestsNative.getNativeBackgroundColor(view); TKUnit.assert(actualResult.hex === expectedNormalizedColor, 'Actual: ' + actualResult.hex + '; Expected: ' + expectedNormalizedColor); diff --git a/apps/automated/src/ui/styling/style-tests.ts b/apps/automated/src/ui/styling/style-tests.ts index 681c2a12ca..0cc5a7c2aa 100644 --- a/apps/automated/src/ui/styling/style-tests.ts +++ b/apps/automated/src/ui/styling/style-tests.ts @@ -602,9 +602,9 @@ export function test_restore_original_values_when_state_is_changed() { page.css = 'button { color: blue; } ' + 'button:pressed { color: red; } '; helper.assertViewColor(btn, '#0000FF'); - btn._goToVisualState('pressed'); + btn._addVisualState('pressed'); helper.assertViewColor(btn, '#FF0000'); - btn._goToVisualState('normal'); + btn._removeVisualState('pressed'); helper.assertViewColor(btn, '#0000FF'); } @@ -655,9 +655,9 @@ export const test_composite_selector_type_class_state = function () { // The button with no class should not react to state changes. TKUnit.assertNull(btnWithNoClass.style.color, 'Color should not have a value.'); - btnWithNoClass._goToVisualState('pressed'); + btnWithNoClass._addVisualState('pressed'); TKUnit.assertNull(btnWithNoClass.style.color, 'Color should not have a value.'); - btnWithNoClass._goToVisualState('normal'); + btnWithNoClass._removeVisualState('pressed'); TKUnit.assertNull(btnWithNoClass.style.color, 'Color should not have a value.'); TKUnit.assertNull(lblWithClass.style.color, 'Color should not have a value'); @@ -864,11 +864,11 @@ function testSelectorsPrioritiesTemplate(css: string) { function testButtonPressedStateIsRed(btn: Button) { TKUnit.assert(btn.style.color === undefined, 'Color should not have a value.'); - btn._goToVisualState('pressed'); + btn._addVisualState('pressed'); helper.assertViewColor(btn, '#FF0000'); - btn._goToVisualState('normal'); + btn._removeVisualState('pressed'); TKUnit.assert(btn.style.color === undefined, 'Color should not have a value after returned to normal state.'); } diff --git a/apps/automated/src/ui/styling/visual-state-tests.ts b/apps/automated/src/ui/styling/visual-state-tests.ts index b48b1e0cd8..2040680f0e 100644 --- a/apps/automated/src/ui/styling/visual-state-tests.ts +++ b/apps/automated/src/ui/styling/visual-state-tests.ts @@ -94,3 +94,54 @@ export var test_goToVisualState_NoState_ShouldGoToNormal = function () { helper.do_PageTest_WithButton(test); }; + +export var test_addVisualState = function () { + var test = function (views: Array) { + (views[0]).css = 'button:hovered { color: red; background-color: orange } button:pressed { color: white }'; + + var btn = views[1]; + + assertInState(btn, btn.defaultVisualState, ['hovered', 'pressed', btn.defaultVisualState]); + + btn._addVisualState('hovered'); + + assertInState(btn, 'hovered', ['hovered', 'pressed', btn.defaultVisualState]); + + TKUnit.assert(types.isDefined(btn.style.color) && btn.style.color.name === 'red'); + TKUnit.assert(types.isDefined(btn.style.backgroundColor) && btn.style.backgroundColor.name === 'orange'); + + btn._addVisualState('pressed'); + + assertInState(btn, 'hovered', ['hovered', btn.defaultVisualState]); + assertInState(btn, 'pressed', ['pressed', btn.defaultVisualState]); + + TKUnit.assert(types.isDefined(btn.style.color) && btn.style.color.name === 'white'); + TKUnit.assert(types.isDefined(btn.style.backgroundColor) && btn.style.backgroundColor.name === 'orange'); + }; + + helper.do_PageTest_WithButton(test); +}; + +export var test_removeVisualState = function () { + var test = function (views: Array) { + (views[0]).css = 'button { background-color: yellow; color: green } button:pressed { background-color: red; color: white }'; + + var btn = views[1]; + + btn._addVisualState('pressed'); + + assertInState(btn, 'pressed', ['pressed', 'hovered', btn.defaultVisualState]); + + TKUnit.assert(types.isDefined(btn.style.color) && btn.style.color.name === 'white'); + TKUnit.assert(types.isDefined(btn.style.backgroundColor) && btn.style.backgroundColor.name === 'red'); + + btn._removeVisualState('pressed'); + + assertInState(btn, btn.defaultVisualState, ['hovered', 'pressed', btn.defaultVisualState]); + + TKUnit.assert(types.isDefined(btn.style.color) && btn.style.color.name === 'green'); + TKUnit.assert(types.isDefined(btn.style.backgroundColor) && btn.style.backgroundColor.name === 'yellow'); + }; + + helper.do_PageTest_WithButton(test); +}; diff --git a/packages/core/ui/button/index.android.ts b/packages/core/ui/button/index.android.ts index fff4d699c3..2660d8c439 100644 --- a/packages/core/ui/button/index.android.ts +++ b/packages/core/ui/button/index.android.ts @@ -44,11 +44,24 @@ function initializeClickListener(): void { ClickListener = ClickListenerImpl; } +function onButtonStateChange(args: TouchGestureEventData) { + const button = args.object as Button; + + switch (args.action) { + case TouchAction.up: + case TouchAction.cancel: + button._removeVisualState('highlighted'); + break; + case TouchAction.down: + button._addVisualState('highlighted'); + break; + } +} + export class Button extends ButtonBase { nativeViewProtected: android.widget.Button; private _stateListAnimator: any; - private _highlightedHandler: (args: TouchGestureEventData) => void; @profile public createNativeView() { @@ -87,22 +100,9 @@ export class Button extends ButtonBase { @PseudoClassHandler('normal', 'highlighted', 'pressed', 'active') _updateButtonStateChangeHandler(subscribe: boolean) { if (subscribe) { - this._highlightedHandler = - this._highlightedHandler || - ((args: TouchGestureEventData) => { - switch (args.action) { - case TouchAction.up: - case TouchAction.cancel: - this._goToVisualState(this.defaultVisualState); - break; - case TouchAction.down: - this._goToVisualState('highlighted'); - break; - } - }); - this.on(GestureTypes[GestureTypes.touch], this._highlightedHandler); + this.on(GestureTypes[GestureTypes.touch], onButtonStateChange); } else { - this.off(GestureTypes[GestureTypes.touch], this._highlightedHandler); + this.off(GestureTypes[GestureTypes.touch], onButtonStateChange); } } diff --git a/packages/core/ui/button/index.ios.ts b/packages/core/ui/button/index.ios.ts index b67e8ca69e..a5f87a7806 100644 --- a/packages/core/ui/button/index.ios.ts +++ b/packages/core/ui/button/index.ios.ts @@ -9,6 +9,8 @@ import { Color } from '../../color'; export * from './button-common'; +const observableVisualStates = ['highlighted']; // States like :disabled are handled elsewhere + export class Button extends ButtonBase { public nativeViewProtected: UIButton; @@ -46,8 +48,12 @@ export class Button extends ButtonBase { _updateButtonStateChangeHandler(subscribe: boolean) { if (subscribe) { if (!this._stateChangedHandler) { - this._stateChangedHandler = new ControlStateChangeListener(this.nativeViewProtected, (s: string) => { - this._goToVisualState(s); + this._stateChangedHandler = new ControlStateChangeListener(this.nativeViewProtected, observableVisualStates, (state: string, add: boolean) => { + if (add) { + this._addVisualState(state); + } else { + this._removeVisualState(state); + } }); } this._stateChangedHandler.start(); diff --git a/packages/core/ui/core/control-state-change/index.android.ts b/packages/core/ui/core/control-state-change/index.android.ts index a87f1b6188..d029e2f401 100644 --- a/packages/core/ui/core/control-state-change/index.android.ts +++ b/packages/core/ui/core/control-state-change/index.android.ts @@ -1,9 +1,9 @@ /* tslint:disable:no-unused-variable */ /* tslint:disable:no-empty */ -import { ControlStateChangeListener as ControlStateChangeListenerDefinition } from '.'; +import { ControlStateChangeListenerCallback, ControlStateChangeListener as ControlStateChangeListenerDefinition } from '.'; export class ControlStateChangeListener implements ControlStateChangeListenerDefinition { - constructor(control: any /* UIControl */, callback: (state: string) => void) { + constructor(control: any /* UIControl */, states: string[], callback: ControlStateChangeListenerCallback) { console.log('ControlStateChangeListener is intended for IOS usage only.'); } public start() {} diff --git a/packages/core/ui/core/control-state-change/index.d.ts b/packages/core/ui/core/control-state-change/index.d.ts index 51ef83d45b..521d86081d 100644 --- a/packages/core/ui/core/control-state-change/index.d.ts +++ b/packages/core/ui/core/control-state-change/index.d.ts @@ -1,4 +1,6 @@ -/** +export type ControlStateChangeListenerCallback = (state: string, add: boolean) => void; + +/** * An utility class used for supporting styling infrastructure. * WARNING: This class is intended for IOS only. */ @@ -8,7 +10,7 @@ export class ControlStateChangeListener { * @param control An instance of the UIControl which state will be watched. * @param callback A callback called when a visual state of the UIControl is changed. */ - constructor(control: any /* UIControl */, callback: (state: string) => void); + constructor(control: any /* UIControl */, states: string[], callback: ControlStateChangeListenerCallback); start(); stop(); diff --git a/packages/core/ui/core/control-state-change/index.ios.ts b/packages/core/ui/core/control-state-change/index.ios.ts index 776e0ce060..d03a85b449 100644 --- a/packages/core/ui/core/control-state-change/index.ios.ts +++ b/packages/core/ui/core/control-state-change/index.ios.ts @@ -1,16 +1,21 @@ -/* tslint:disable:no-unused-variable */ -import { ControlStateChangeListener as ControlStateChangeListenerDefinition } from '.'; +import { ControlStateChangeListenerCallback, ControlStateChangeListener as ControlStateChangeListenerDefinition } from '.'; @NativeClass class ObserverClass extends NSObject { - // NOTE: Refactor this - use Typescript property instead of strings.... - observeValueForKeyPathOfObjectChangeContext(path: string) { - if (path === 'selected') { - this['_owner']._onSelectedChanged(); - } else if (path === 'enabled') { - this['_owner']._onEnabledChanged(); - } else if (path === 'highlighted') { - this['_owner']._onHighlightedChanged(); + public callback: WeakRef; + + public static initWithCallback(callback: WeakRef): ObserverClass { + const observer = ObserverClass.alloc().init(); + observer.callback = callback; + + return observer; + } + + public observeValueForKeyPathOfObjectChangeContext(path: string, object: UIControl) { + const callback = this.callback?.deref(); + + if (callback) { + callback(path, object[path]); } } } @@ -18,52 +23,33 @@ class ObserverClass extends NSObject { export class ControlStateChangeListener implements ControlStateChangeListenerDefinition { private _observer: NSObject; private _control: UIControl; - private _observing = false; + private _observing: boolean = false; - private _callback: (state: string) => void; + private readonly _states: string[]; - constructor(control: UIControl, callback: (state: string) => void) { - this._observer = ObserverClass.alloc().init(); - this._observer['_owner'] = this; + constructor(control: UIControl, states: string[], callback: ControlStateChangeListenerCallback) { this._control = control; - this._callback = callback; + this._states = states; + this._observer = ObserverClass.initWithCallback(new WeakRef(callback)); } public start() { if (!this._observing) { - this._control.addObserverForKeyPathOptionsContext(this._observer, 'highlighted', NSKeyValueObservingOptions.New, null); this._observing = true; - this._updateState(); + + for (const state of this._states) { + this._control.addObserverForKeyPathOptionsContext(this._observer, state, NSKeyValueObservingOptions.New, null); + } } } public stop() { if (this._observing) { - this._observing = false; - this._control.removeObserverForKeyPath(this._observer, 'highlighted'); - } - } - - //@ts-ignore - private _onEnabledChanged() { - this._updateState(); - } - - //@ts-ignore - private _onSelectedChanged() { - this._updateState(); - } - - //@ts-ignore - private _onHighlightedChanged() { - this._updateState(); - } + for (const state of this._states) { + this._control.removeObserverForKeyPath(this._observer, state); + } - private _updateState() { - let state = 'normal'; - if (this._control.highlighted) { - state = 'highlighted'; + this._observing = false; } - this._callback(state); } } diff --git a/packages/core/ui/core/view-base/index.ts b/packages/core/ui/core/view-base/index.ts index cfa0057405..2b0cf78c23 100644 --- a/packages/core/ui/core/view-base/index.ts +++ b/packages/core/ui/core/view-base/index.ts @@ -345,7 +345,12 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition private _androidView: Object; private _style: Style; private _isLoaded: boolean; + + /** + * @deprecated + */ private _visualState: string; + private _templateParent: ViewBase; private __nativeView: any; // private _disableNativeViewRecycling: boolean; @@ -557,10 +562,18 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition */ public reusable: boolean; + public readonly cssClasses: Set; + public readonly cssPseudoClasses: Set; + constructor() { super(); this._domId = viewIdCounter++; this._style = new Style(new WeakRef(this)); + this.cssClasses = new Set(); + this.cssPseudoClasses = new Set(); + + this.cssPseudoClasses.add(this.defaultVisualState); + this.notify({ eventName: ViewBase.createdEvent, type: this.constructor.name, object: this }); } @@ -800,14 +813,11 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition highlighted: ['active', 'pressed'], }; - public cssClasses: Set = new Set(); - public cssPseudoClasses: Set = new Set(); + private getAllAliasedStates(name: string): string[] { + const allStates: string[] = [name]; - private getAllAliasedStates(name: string): Array { - const allStates = []; - allStates.push(name); if (name in this.pseudoClassAliases) { - for (let i = 0; i < this.pseudoClassAliases[name].length; i++) { + for (let i = 0, length = this.pseudoClassAliases[name].length; i < length; i++) { allStates.push(this.pseudoClassAliases[name][i]); } } @@ -823,7 +833,7 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition @profile public addPseudoClass(name: string): void { const allStates = this.getAllAliasedStates(name); - for (let i = 0; i < allStates.length; i++) { + for (let i = 0, length = allStates.length; i < length; i++) { if (!this.cssPseudoClasses.has(allStates[i])) { this.cssPseudoClasses.add(allStates[i]); this.notifyPseudoClassChanged(allStates[i]); @@ -839,7 +849,7 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition @profile public deletePseudoClass(name: string): void { const allStates = this.getAllAliasedStates(name); - for (let i = 0; i < allStates.length; i++) { + for (let i = 0, length = allStates.length; i < length; i++) { if (this.cssPseudoClasses.has(allStates[i])) { this.cssPseudoClasses.delete(allStates[i]); this.notifyPseudoClassChanged(allStates[i]); @@ -1334,11 +1344,32 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition view._isAddedToNativeVisualTree = false; } + /** + * @deprecated + */ public get visualState() { return this._visualState; } + public _addVisualState(state: string): void { + this.deletePseudoClass(this.defaultVisualState); + this.addPseudoClass(state); + } + + public _removeVisualState(state: string): void { + this.deletePseudoClass(state); + + if (!this.cssPseudoClasses.size) { + this.addPseudoClass(this.defaultVisualState); + } + } + + /** + * @deprecated Use View._addVisualState() and View._removeVisualState() instead. + */ public _goToVisualState(state: string) { + console.log('_goToVisualState() is deprecated. Use View._addVisualState() and View._removeVisualState() instead.'); + if (Trace.isEnabled()) { Trace.write(this + ' going to state: ' + state, Trace.categories.Style); } @@ -1584,6 +1615,21 @@ export const idProperty = new Property({ }); idProperty.register(ViewBase); +export const defaultVisualStateProperty = new Property({ + name: 'defaultVisualState', + defaultValue: 'normal', + valueChanged(this: void, target, oldValue, newValue): void { + const value = newValue || 'normal'; + + // Append new default if old one is currently applied + if (target.cssPseudoClasses && target.cssPseudoClasses.has(oldValue)) { + target.deletePseudoClass(oldValue); + target.addPseudoClass(newValue); + } + }, +}); +defaultVisualStateProperty.register(ViewBase); + export function booleanConverter(v: string | boolean): boolean { const lowercase = (v + '').toLowerCase(); if (lowercase === 'true') { diff --git a/packages/core/ui/core/view/index.d.ts b/packages/core/ui/core/view/index.d.ts index 410282516b..91e4e39e4f 100644 --- a/packages/core/ui/core/view/index.d.ts +++ b/packages/core/ui/core/view/index.d.ts @@ -1022,6 +1022,15 @@ export abstract class View extends ViewCommon { /** * @private */ + _addVisualState(state: string): void; + /** + * @private + */ + _removeVisualState(state: string): void; + /** + * @deprecated Use View.addPseudoClass() and View.deletePseudoClass() instead. + * @private + */ _goToVisualState(state: string); /** * @private diff --git a/packages/core/ui/core/view/view-common.ts b/packages/core/ui/core/view/view-common.ts index 8103907f8b..676c4cbd7c 100644 --- a/packages/core/ui/core/view/view-common.ts +++ b/packages/core/ui/core/view/view-common.ts @@ -24,7 +24,7 @@ import { StyleScope } from '../../styling/style-scope'; import { LinearGradient } from '../../styling/linear-gradient'; import * as am from '../../animation'; -import { AccessibilityEventOptions, AccessibilityLiveRegion, AccessibilityRole, AccessibilityState, AccessibilityTrait } from '../../../accessibility/accessibility-types'; +import { AccessibilityEventOptions, AccessibilityLiveRegion, AccessibilityRole, AccessibilityState } from '../../../accessibility/accessibility-types'; import { accessibilityHintProperty, accessibilityIdentifierProperty, accessibilityLabelProperty, accessibilityValueProperty, accessibilityIgnoresInvertColorsProperty } from '../../../accessibility/accessibility-properties'; import { accessibilityBlurEvent, accessibilityFocusChangedEvent, accessibilityFocusEvent, accessibilityPerformEscapeEvent, getCurrentFontScale } from '../../../accessibility'; import { ShadowCSSValues } from '../../styling/css-shadow'; @@ -1266,24 +1266,18 @@ export const originYProperty = new Property({ }); originYProperty.register(ViewCommon); -export const defaultVisualStateProperty = new Property({ - name: 'defaultVisualState', - defaultValue: 'normal', - valueChanged(this: void, target, oldValue, newValue): void { - target.defaultVisualState = newValue || 'normal'; - if (!target.visualState || target.visualState === oldValue) { - target._goToVisualState(target.defaultVisualState); - } - }, -}); -defaultVisualStateProperty.register(ViewCommon); - export const isEnabledProperty = new Property({ name: 'isEnabled', defaultValue: true, valueConverter: booleanConverter, valueChanged(this: void, target, oldValue, newValue): void { - target._goToVisualState(newValue ? target.defaultVisualState : 'disabled'); + const state = 'disabled'; + + if (newValue) { + target._removeVisualState(state); + } else { + target._addVisualState(state); + } }, }); isEnabledProperty.register(ViewCommon); diff --git a/packages/core/ui/editable-text-base/editable-text-base-common.ts b/packages/core/ui/editable-text-base/editable-text-base-common.ts index 9f28d31e95..5ed2fee137 100644 --- a/packages/core/ui/editable-text-base/editable-text-base-common.ts +++ b/packages/core/ui/editable-text-base/editable-text-base-common.ts @@ -6,6 +6,19 @@ import { booleanConverter } from '../core/view-base'; import { Style } from '../styling/style'; import { Color } from '../../color'; import { CoreTypes } from '../../core-types'; +import { EventData } from '../../data/observable'; + +function focusChangeHandler(args: EventData): void { + const view = args.object as EditableTextBase; + + if (args.eventName === 'focus') { + view._addVisualState('focus'); + view._removeVisualState('blur'); + } else { + view._addVisualState('blur'); + view._removeVisualState('focus'); + } +} export abstract class EditableTextBase extends TextBase implements EditableTextBaseDefinition { public static blurEvent = 'blur'; @@ -28,15 +41,12 @@ export abstract class EditableTextBase extends TextBase implements EditableTextB public abstract setSelection(start: number, stop?: number); placeholderColor: Color; - private _focusHandler = () => this._goToVisualState('focus'); - private _blurHandler = () => this._goToVisualState('blur'); - @PseudoClassHandler('focus', 'blur') _updateTextBaseFocusStateHandler(subscribe) { const method = subscribe ? 'on' : 'off'; - this[method]('focus', this._focusHandler); - this[method]('blur', this._blurHandler); + this[method]('focus', focusChangeHandler); + this[method]('blur', focusChangeHandler); } } diff --git a/packages/core/ui/search-bar/search-bar-common.ts b/packages/core/ui/search-bar/search-bar-common.ts index 77cb4422db..5fed56d4d4 100644 --- a/packages/core/ui/search-bar/search-bar-common.ts +++ b/packages/core/ui/search-bar/search-bar-common.ts @@ -7,6 +7,7 @@ import { Color } from '../../color'; export abstract class SearchBarBase extends View implements SearchBarDefinition { public static submitEvent = 'submit'; public static clearEvent = 'clear'; + public text: string; public hint: string; public textFieldBackgroundColor: Color; diff --git a/packages/core/ui/switch/switch-common.ts b/packages/core/ui/switch/switch-common.ts index 3fc1c7af8c..a64a1efa7c 100644 --- a/packages/core/ui/switch/switch-common.ts +++ b/packages/core/ui/switch/switch-common.ts @@ -13,17 +13,17 @@ export class SwitchBase extends View implements SwitchDefinition { _onCheckedPropertyChanged(newValue: boolean) { if (newValue) { - this.addPseudoClass('checked'); + this._addVisualState('checked'); } else { - this.deletePseudoClass('checked'); + this._removeVisualState('checked'); } } } SwitchBase.prototype.recycleNativeView = 'auto'; -function onCheckedPropertyChanged(switchBase: SwitchBase, oldValue: boolean, newValue: boolean) { - switchBase._onCheckedPropertyChanged(newValue); +function onCheckedPropertyChanged(target: SwitchBase, oldValue: boolean, newValue: boolean) { + target._onCheckedPropertyChanged(newValue); } export const checkedProperty = new Property({ diff --git a/packages/core/ui/text-field/text-field-common.ts b/packages/core/ui/text-field/text-field-common.ts index a6ca9ece6a..604902d881 100644 --- a/packages/core/ui/text-field/text-field-common.ts +++ b/packages/core/ui/text-field/text-field-common.ts @@ -7,6 +7,7 @@ import { booleanConverter } from '../core/view-base'; @CSSType('TextField') export class TextFieldBase extends EditableTextBase implements TextFieldDefinition { public static returnPressEvent = 'returnPress'; + public secure: boolean; public closeOnReturn: boolean; // iOS only (to avoid 12+ suggested strong password handling) diff --git a/packages/core/ui/text-view/text-view-common.ts b/packages/core/ui/text-view/text-view-common.ts index ad4dc5bc61..da72653dde 100644 --- a/packages/core/ui/text-view/text-view-common.ts +++ b/packages/core/ui/text-view/text-view-common.ts @@ -3,5 +3,6 @@ import { EditableTextBase } from '../editable-text-base'; export class TextViewBase extends EditableTextBase implements TextViewDefinition { public static returnPressEvent = 'returnPress'; + public maxLines: number; }