Skip to content

Dynamic Behavior ​

Properties can react to each other through conditions (visibility/enabled) and derivations (auto-computed values). This guide covers both mechanisms in depth.

Conditions ​

Conditions are declarative objects that control visibility and enabled state. They are evaluated automatically when dependent property values change.

Visibility vs. Enabled ​

AspectvisibleConditionenabledCondition
When falseProperty is hidden from the editorProperty is shown but grayed out / non-editable
MechanismValueNotifier<bool> on visibleNotifierPropertyControl.markAsDisabled()
UI updateValueListenableBuilder triggers rebuildFormControl.disabled state change
Value preservedYes, value is retained when hiddenYes, value is retained when disabled

Chaining Conditions ​

Build complex visibility rules by combining conditions:

dart
// Show "Premium Features" group only when:
// - Plan is not "free"
// - AND trial has not expired
// - AND user has accepted terms

b.boolean('acceptedTerms', 'Accept Terms');
b.enumeration<String>('plan', 'Plan', options: [...]);
b.boolean('trialExpired', 'Trial Expired');

b.group('premium', 'Premium Features');

b.integer('maxUsers', 'Max Users',
  defaultValue: 10,
  visibleCondition: PropertyCollectionBuilder.whenAll([
    PropertyCollectionBuilder.whenNot<String>('plan', 'free'),
    PropertyCollectionBuilder.whenFalse('trialExpired'),
    PropertyCollectionBuilder.whenTrue('acceptedTerms'),
  ]),
);

Mixing Visibility and Enabled ​

A property can have both conditions:

dart
b.string('apiKey', 'API Key',
  // Only visible when external data source is selected
  visibleCondition: PropertyCollectionBuilder.when<String>(
    'dataSource', 'external',
  ),
  // Only editable when not in read-only mode
  enabledCondition: PropertyCollectionBuilder.whenFalse('readOnly'),
);

Custom Conditions for Complex Logic ​

When built-in conditions are insufficient, use CustomCondition:

dart
b.string('discount', 'Discount Code',
  visibleCondition: PropertyCollectionBuilder.whenCustom(
    dependencies: ['level', 'capacity', 'enrollmentType'],
    evaluator: (collection) {
      final level = collection.getValue<CourseLevel>('level');
      final capacity = collection.getValue<int>('capacity');
      final enrollment = collection.getValue<EnrollmentType>('enrollmentType');

      // Complex business rule
      return level == CourseLevel.advanced &&
             capacity > 20 &&
             enrollment == EnrollmentType.limited;
    },
  ),
);

Derivations ​

Derivations auto-compute a property value from other properties. Unlike conditions (which control display), derivations modify actual values.

Basic Derivation ​

dart
b.string('title', 'Title', required: true);
b.string('slug', 'URL Slug', required: true);

// slug = toIdentifier(title)
b.derivedProperty('slug',
  from: {'title'},
  transformer: (values) =>
    PropertyValidators.toIdentifier(values['title'] as String?),
);

Multi-Source Derivation ​

A derivation can depend on multiple source properties:

dart
b.string('firstName', 'First Name');
b.string('lastName', 'Last Name');
b.string('displayName', 'Display Name');

b.derivedProperty('displayName',
  from: {'firstName', 'lastName'},
  transformer: (values) {
    final first = values['firstName'] as String? ?? '';
    final last = values['lastName'] as String? ?? '';
    return '$first $last'.trim();
  },
);

Derivation Lifecycle ​

  1. Source changes -- When any source property changes, the transformer is called
  2. Target updates -- The computed value is set on the target property
  3. User overrides -- If the user manually edits the target, the derivation stops (dirty flag set)
  4. User clears -- If the user clears the target value, the derivation resumes

This behavior is managed internally via a counter-based mechanism that distinguishes programmatic updates from user edits.

Loading Existing Data ​

When loading saved data into a form, you typically want to prevent derivations from overwriting stored values:

dart
final collection = buildCourseSettings();
collection.fromJson(savedData);

// Prevent slug from being re-derived from the saved title
collection.markDerivationsAsDirty();

Initially Dirty ​

Alternatively, set initiallyDirty: true on the derivation:

dart
b.derivedProperty('slug',
  from: {'title'},
  transformer: (values) => ...,
  initiallyDirty: true,  // skip auto-derivation on init
);

Reactive Updates in the UI ​

The PropertyCollectionEditor handles reactivity automatically. Here is what happens when a value changes:

Manual Reactivity ​

If you are not using PropertyCollectionEditor, you can react to changes manually:

dart
// Listen to a single property
property.control.valueChanges.listen((value) {
  print('Value changed to: $value');
});

// Listen to visibility
property.visibleNotifier.addListener(() {
  print('Visibility changed to: ${property.visible}');
});

// Listen to all changes in the collection
collection.valueChanges.listen((values) {
  print('Any value changed: $values');
});

Combining Conditions and Derivations ​

Conditions and derivations work independently but can complement each other:

dart
b.group('billing', 'Billing');

b.enumeration<String>('billingType', 'Billing Type',
  options: [
    EnumOption('free', 'Free'),
    EnumOption('paid', 'Paid'),
    EnumOption('subscription', 'Subscription'),
  ],
  defaultValue: 'free',
);

// Only visible when paid
b.decimal('price', 'Price',
  defaultValue: 0.0,
  visibleCondition: PropertyCollectionBuilder.whenIn<String>(
    'billingType', {'paid', 'subscription'},
  ),
);

// Derived: tax = price * 0.1, only visible when price is visible
b.decimal('tax', 'Tax (auto-computed)',
  defaultValue: 0.0,
  visibleCondition: PropertyCollectionBuilder.whenIn<String>(
    'billingType', {'paid', 'subscription'},
  ),
);

b.derivedProperty('tax',
  from: {'price'},
  transformer: (values) {
    final price = values['price'] as double? ?? 0.0;
    return price * 0.1;
  },
);

Next Steps ​