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