feat(core): support for simultaneous pseudo states (#10656)

This commit is contained in:
Dimitris-Rafail Katsampas
2025-01-13 05:18:08 +02:00
committed by GitHub
parent a883a79e3b
commit f970455007
16 changed files with 210 additions and 103 deletions

View File

@ -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);

View File

@ -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.');
}

View File

@ -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<view.View>) {
(<page.Page>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<view.View>) {
(<page.Page>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);
};

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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() {}

View File

@ -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();

View File

@ -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<ControlStateChangeListenerCallback>;
public static initWithCallback(callback: WeakRef<ControlStateChangeListenerCallback>): ObserverClass {
const observer = <ObserverClass>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) {
for (const state of this._states) {
this._control.removeObserverForKeyPath(this._observer, state);
}
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();
}
private _updateState() {
let state = 'normal';
if (this._control.highlighted) {
state = 'highlighted';
}
this._callback(state);
}
}

View File

@ -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<string>;
public readonly cssPseudoClasses: Set<string>;
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<string> = new Set();
public cssPseudoClasses: Set<string> = new Set();
private getAllAliasedStates(name: string): string[] {
const allStates: string[] = [name];
private getAllAliasedStates(name: string): Array<string> {
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<ViewBase, string>({
});
idProperty.register(ViewBase);
export const defaultVisualStateProperty = new Property<ViewBase, string>({
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') {

View File

@ -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

View File

@ -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<ViewCommon, number>({
});
originYProperty.register(ViewCommon);
export const defaultVisualStateProperty = new Property<ViewCommon, string>({
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<ViewCommon, boolean>({
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);

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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<SwitchBase, boolean>({

View File

@ -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)

View File

@ -3,5 +3,6 @@ import { EditableTextBase } from '../editable-text-base';
export class TextViewBase extends EditableTextBase implements TextViewDefinition {
public static returnPressEvent = 'returnPress';
public maxLines: number;
}