Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/core/data/observable/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ export interface EventDataValue extends EventData {
value?: boolean;
}

/**
* How a property change was produced.
* - `script`: written by application code, including the local value of a style property.
* - `native`: reported by the native view, e.g. after user interaction.
* - `css`: applied from a css declaration.
* - `inherited`: propagated from the parent's value.
* - `animation`: written by a css keyframe animation.
*/
export type PropertyChangeOrigin = 'script' | 'native' | 'css' | 'inherited' | 'animation';

/**
* Data for the "propertyChange" event.
*/
Expand All @@ -34,6 +44,10 @@ export interface PropertyChangeData extends EventData {
* The previous value of the property.
*/
oldValue?: any;
/**
* How the change was produced. `undefined` for plain Observable.notifyPropertyChange callers.
*/
origin?: PropertyChangeOrigin;
}

interface ListenerEntry {
Expand Down
19 changes: 15 additions & 4 deletions packages/core/ui/core/properties/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ViewBase } from '../view-base';
import { PropertyChangeData, WrappedValue } from '../../../data/observable';
import { PropertyChangeData, PropertyChangeOrigin, WrappedValue } from '../../../data/observable';
import { Trace } from '../../../trace';

import { Style } from '../../styling/style';
Expand Down Expand Up @@ -299,7 +299,7 @@ export class Property<T extends ViewBase, U> implements TypedPropertyDescriptor<
public isStyleProperty: boolean;

public get: () => U;
public set: (value: U) => void;
public set: (value: U, origin?: PropertyChangeOrigin) => void;
public overrideHandlers: (options: PropertyOptions<T, U>) => void;
public enumerable = true;
public configurable = true;
Expand Down Expand Up @@ -349,7 +349,7 @@ export class Property<T extends ViewBase, U> implements TypedPropertyDescriptor<

const property = this;

this.set = function (this: T, boxedValue: U): void {
this.set = function (this: T, boxedValue: U, origin?: PropertyChangeOrigin): void {
const reset = isResetValue(boxedValue);
let value: U;
let wrapped: boolean;
Expand Down Expand Up @@ -415,6 +415,7 @@ export class Property<T extends ViewBase, U> implements TypedPropertyDescriptor<
propertyName,
value,
oldValue,
origin: origin ?? 'script',
});
}

Expand Down Expand Up @@ -452,6 +453,7 @@ export class Property<T extends ViewBase, U> implements TypedPropertyDescriptor<
propertyName,
value,
oldValue,
origin: 'native',
});
}

Expand Down Expand Up @@ -599,6 +601,7 @@ export class CoercibleProperty<T extends ViewBase, U> extends Property<T, U> imp
propertyName,
value,
oldValue,
origin: 'script',
});
}

Expand Down Expand Up @@ -658,7 +661,7 @@ export class InheritedProperty<T extends ViewBase, U> extends Property<T, U> imp

// take currentValue before calling base - base may change it.
const currentValue = that[key];
setBase.call(that, unboxedValue);
setBase.call(that, unboxedValue, valueSource === ValueSource.Local ? 'script' : 'inherited');

const newValue = that[key];
that[sourceKey] = newValueSource;
Expand Down Expand Up @@ -837,6 +840,7 @@ export class CssProperty<T extends Style, U> {
propertyName,
value,
oldValue,
origin: 'script',
});
}

Expand Down Expand Up @@ -921,6 +925,7 @@ export class CssProperty<T extends Style, U> {
propertyName,
value,
oldValue,
origin: 'css',
});
}

Expand Down Expand Up @@ -1041,6 +1046,8 @@ export class CssAnimationProperty<T extends Style, U> implements CssAnimationPro
const property = this;

function descriptor(symbol: symbol, propertySource: ValueSource, enumerable: boolean, configurable: boolean, getsComputed: boolean): PropertyDescriptor {
const origin: PropertyChangeOrigin = propertySource === ValueSource.Keyframe ? 'animation' : propertySource === ValueSource.Css ? 'css' : 'script';

return {
enumerable,
configurable,
Expand Down Expand Up @@ -1128,6 +1135,7 @@ export class CssAnimationProperty<T extends Style, U> implements CssAnimationPro
propertyName,
value,
oldValue,
origin,
});
}
},
Expand Down Expand Up @@ -1231,6 +1239,8 @@ export class InheritedCssProperty<T extends Style, U> extends CssProperty<T, U>

const setFunc = (valueSource: ValueSource) => {
const isLocalWrite = valueSource === ValueSource.Local;
// Default is only used by the cascade below, to reset a child that inherited this value.
const origin: PropertyChangeOrigin = isLocalWrite ? 'script' : valueSource === ValueSource.Css ? 'css' : 'inherited';

return function (this: T, boxedValue: any): void {
const view = this.viewRef.get();
Expand Down Expand Up @@ -1320,6 +1330,7 @@ export class InheritedCssProperty<T extends Style, U> extends CssProperty<T, U>
propertyName,
value,
oldValue,
origin,
});
}

Expand Down
164 changes: 164 additions & 0 deletions packages/core/ui/core/properties/property-change-origin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest';

import { CoercibleProperty, Property } from '.';
import { View } from '../view';
import { Label } from '../../label';
import { StackLayout } from '../../layouts/stack-layout';
import { Color } from '../../../color';
import { Observable, PropertyChangeData } from '../../../data/observable';
import { opacityProperty } from '../../styling/style-properties';

class TestView extends View {
public test: string;
public coerced: number;

public createNativeView() {
return {};
}
}

const testProperty = new Property<TestView, string>({
name: 'test',
defaultValue: undefined,
});
testProperty.register(TestView);

const coercedProperty = new CoercibleProperty<TestView, number>({
name: 'coerced',
defaultValue: 0,
coerceValue: (target, value) => value,
});
coercedProperty.register(TestView);

function record(target: Observable, eventName: string): PropertyChangeData[] {
const events: PropertyChangeData[] = [];
target.on(eventName, (data: PropertyChangeData) => events.push(data));

return events;
}

describe('PropertyChangeData.origin', () => {
it('is script for a local set of a Property', () => {
const view = new TestView();
const events = record(view, 'testChange');

view.test = 'value';

expect(events.map((e) => e.origin)).toEqual(['script']);
});

it('is script for a local set of a CoercibleProperty', () => {
const view = new TestView();
const events = record(view, 'coercedChange');

view.coerced = 5;

expect(events.map((e) => e.origin)).toEqual(['script']);
});

it('is native for nativeValueChange', () => {
const view = new TestView();
const events = record(view, 'testChange');

testProperty.nativeValueChange(view, 'from-native');

expect(events.map((e) => e.origin)).toEqual(['native']);
});

it('is script for a local set of a style property', () => {
const view = new Label();
const events = record(view.style, 'colorChange');

view.style.color = new Color('red');

expect(events.map((e) => e.origin)).toEqual(['script']);
});

it('is css for a css set of a style property', () => {
const view = new Label();
const events = record(view.style, 'colorChange');

view.style['css:color'] = 'red';

expect(events.map((e) => e.origin)).toEqual(['css']);
});

it('is inherited for a css value propagated to a child', () => {
const parent = new StackLayout();
const child = new Label();
parent.addChild(child);

const parentEvents = record(parent.style, 'colorChange');
const childEvents = record(child.style, 'colorChange');

parent.style.color = new Color('blue');

expect(parentEvents.map((e) => e.origin)).toEqual(['script']);
expect(childEvents.map((e) => e.origin)).toEqual(['inherited']);
expect(child.style.color.hex).toBe(parent.style.color.hex);
});

it('is inherited for a view property propagated to a child', () => {
const parent = new StackLayout();
const child = new Label();
parent.addChild(child);

const parentEvents = record(parent, 'bindingContextChange');
const childEvents = record(child, 'bindingContextChange');

parent.bindingContext = { name: 'ctx' };

expect(parentEvents.map((e) => e.origin)).toEqual(['script']);
expect(childEvents.map((e) => e.origin)).toEqual(['inherited']);
});

it('is animation for a keyframe set', () => {
const view = new Label();
const events = record(view.style, 'opacityChange');

view.style[opacityProperty.keyframe] = 0.5;

expect(events.map((e) => e.origin)).toEqual(['animation']);
});

it('is css for a css set of an animatable property', () => {
const view = new Label();
const events = record(view.style, 'opacityChange');

view.style['css:opacity'] = 0.5;

expect(events.map((e) => e.origin)).toEqual(['css']);
});

it('is script on the longhands of a local shorthand set', () => {
const view = new Label();
const top = record(view.style, 'marginTopChange');
const left = record(view.style, 'marginLeftChange');

view.style.margin = '5';

expect(top.map((e) => e.origin)).toEqual(['script']);
expect(left.map((e) => e.origin)).toEqual(['script']);
});

it('is css on the longhands of a css shorthand set', () => {
const view = new Label();
const top = record(view.style, 'marginTopChange');
const left = record(view.style, 'marginLeftChange');

view.style['css:margin'] = '5';

expect(top.map((e) => e.origin)).toEqual(['css']);
expect(left.map((e) => e.origin)).toEqual(['css']);
});

it('is undefined for Observable.notifyPropertyChange', () => {
const observable = new Observable();
const events = record(observable, Observable.propertyChangeEvent);

observable.notifyPropertyChange('name', 'new', 'old');

expect(events.length).toBe(1);
expect(events[0].origin).toBeUndefined();
});
});
Loading