Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,13 @@ class CupertinoSlidingSegmentedButtonControl extends StatelessWidget {
debugPrint("CupertinoSlidingSegmentedButtonControl build: ${control.id}");

var controls = control.buildWidgets("controls");

if (controls.length < 2) {
return const ErrorControl(
"CupertinoSlidingSegmentedButton must have at minimum two visible controls");
}

var button = CupertinoSlidingSegmentedControl(
groupValue: control.getInt("selected_index"),
groupValue: control.getInt("selected_index", 0)!,
proportionalWidth: control.getBool("proportional_width", false)!,
backgroundColor: control.getColor(
"bgcolor", context, CupertinoColors.tertiarySystemFill)!,
Expand All @@ -36,15 +35,13 @@ class CupertinoSlidingSegmentedButtonControl extends StatelessWidget {
"thumb_color",
context,
const CupertinoDynamicColor.withBrightness(
color: Color(0xFFFFFFFF),
darkColor: Color(0xFF636366),
))!,
color: Color(0xFFFFFFFF), darkColor: Color(0xFF636366)))!,
children: controls.asMap().map((i, c) => MapEntry(i, c)),
onValueChanged: (int? index) {
if (!control.disabled) {
control
.updateProperties({"selected_index": index ?? 0}, notify: true);
control.triggerEvent("change", index ?? 0);
index = index ?? 0;
control.updateProperties({"selected_index": index}, notify: true);
control.triggerEvent("change", index);
}
},
);
Expand Down
155 changes: 87 additions & 68 deletions packages/flet/lib/src/controls/expansion_tile.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import 'package:flet/src/utils/animations.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

import '../extensions/control.dart';
import '../models/control.dart';
Expand All @@ -11,97 +13,114 @@ import '../utils/numbers.dart';
import '../utils/theme.dart';
import '../widgets/error.dart';
import 'base_controls.dart';
import 'control_widget.dart';

class ExpansionTileControl extends StatelessWidget {
class ExpansionTileControl extends StatefulWidget {
final Control control;

const ExpansionTileControl({
super.key,
required this.control,
});
const ExpansionTileControl({super.key, required this.control});

@override
Widget build(BuildContext context) {
debugPrint("ExpansionTile build: ${control.id}");
State<ExpansionTileControl> createState() => _ExpansionTileControlState();
}

class _ExpansionTileControlState extends State<ExpansionTileControl> {
late final ExpansibleController _controller;
bool _expanded = false;

@override
void initState() {
super.initState();
_controller = ExpansibleController();
_expanded = widget.control.getBool("expanded", false)!;
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

var controls = control
.children("controls")
.map((child) => ControlWidget(control: child, key: ValueKey(child.id)))
.toList();
// Schedules an update to the controller after the current frame.
// This ensures the expansion/collapse animation is triggered safely.
void _scheduleControllerUpdate(bool expanded) {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; // Prevents updates if the widget is disposed.

var leading = control.buildIconOrWidget("leading");
var title = control.buildTextOrWidget("title");
var subtitle = control.buildTextOrWidget("subtitle");
var trailing = control.buildIconOrWidget("trailing");
if (expanded) {
_controller.expand();
} else {
_controller.collapse();
}
});
}

@override
Widget build(BuildContext context) {
debugPrint("ExpansionTile build: ${widget.control.id}");

final title = widget.control.buildTextOrWidget("title");
if (title == null) {
return const ErrorControl(
"ExpansionTile.title must be provided and visible");
}

bool maintainState = control.getBool("maintain_state", false)!;
bool initiallyExpanded = control.getBool("initially_expanded", false)!;

var iconColor = control.getColor("icon_color", context);
var textColor = control.getColor("text_color", context);
var bgColor = control.getColor("bgcolor", context);
var collapsedBgColor = control.getColor("collapsed_bgcolor", context);
var collapsedIconColor = control.getColor("collapsed_icon_color", context);
var collapsedTextColor = control.getColor("collapsed_text_color", context);

var affinity = control.getListTileControlAffinity(
"affinity", ListTileControlAffinity.platform)!;
var clipBehavior = parseClip(control.getString("clip_behavior"));
var expanded = widget.control.getBool("expanded", false)!;
if (_expanded != expanded) {
_expanded = expanded;
_scheduleControllerUpdate(expanded);
}

var expandedCrossAxisAlignment = control.getCrossAxisAlignment(
var expandedCrossAxisAlignment = widget.control.getCrossAxisAlignment(
"expanded_cross_axis_alignment", CrossAxisAlignment.center)!;

if (expandedCrossAxisAlignment == CrossAxisAlignment.baseline) {
return const ErrorControl(
'CrossAxisAlignment.baseline is not supported since the expanded '
'CrossAxisAlignment.BASELINE is not supported since the expanded '
'controls are aligned in a column, not a row. '
'Try aligning the controls differently.');
}

Function(bool)? onChange = !control.disabled
? (expanded) {
control.triggerEvent("change", expanded);
}
: null;

Widget tile = ExpansionTile(
controlAffinity: affinity,
childrenPadding: control.getPadding("controls_padding"),
tilePadding: control.getEdgeInsets("tile_padding"),
expandedAlignment: control.getAlignment("expanded_alignment"),
expandedCrossAxisAlignment:
control.getCrossAxisAlignment("expanded_cross_axis_alignment"),
backgroundColor: bgColor,
iconColor: iconColor,
textColor: textColor,
collapsedBackgroundColor: collapsedBgColor,
collapsedIconColor: collapsedIconColor,
collapsedTextColor: collapsedTextColor,
maintainState: maintainState,
initiallyExpanded: initiallyExpanded,
clipBehavior: clipBehavior,
shape: control.getShape("shape", Theme.of(context)),
collapsedShape: control.getShape("collapsed_shape", Theme.of(context)),
onExpansionChanged: onChange,
visualDensity: control.getVisualDensity("visual_density"),
enableFeedback: control.getBool("enable_feedback"),
showTrailingIcon: control.getBool("show_trailing_icon", true)!,
enabled: !control.disabled,
minTileHeight: control.getDouble("min_tile_height"),
dense: control.getBool("dense"),
leading: leading,
final tile = ExpansionTile(
controller: _controller,
controlAffinity: widget.control.getListTileControlAffinity("affinity"),
childrenPadding: widget.control.getPadding("controls_padding"),
tilePadding: widget.control.getEdgeInsets("tile_padding"),
expandedAlignment: widget.control.getAlignment("expanded_alignment"),
expandedCrossAxisAlignment: expandedCrossAxisAlignment,
backgroundColor: widget.control.getColor("bgcolor", context),
iconColor: widget.control.getColor("icon_color", context),
textColor: widget.control.getColor("text_color", context),
collapsedBackgroundColor:
widget.control.getColor("collapsed_bgcolor", context),
collapsedIconColor:
widget.control.getColor("collapsed_icon_color", context),
collapsedTextColor:
widget.control.getColor("collapsed_text_color", context),
maintainState: widget.control.getBool("maintain_state", false)!,
initiallyExpanded: expanded,
clipBehavior: widget.control.getClipBehavior("clip_behavior"),
shape: widget.control.getShape("shape", Theme.of(context)),
collapsedShape:
widget.control.getShape("collapsed_shape", Theme.of(context)),
onExpansionChanged: (bool expanded) {
_expanded = expanded;
widget.control.updateProperties({"expanded": expanded});
widget.control.triggerEvent("change", expanded);
},
visualDensity: widget.control.getVisualDensity("visual_density"),
enableFeedback: widget.control.getBool("enable_feedback"),
showTrailingIcon: widget.control.getBool("show_trailing_icon", true)!,
enabled: !widget.control.disabled,
minTileHeight: widget.control.getDouble("min_tile_height"),
dense: widget.control.getBool("dense"),
expansionAnimationStyle:
widget.control.getAnimationStyle("animation_style"),
leading: widget.control.buildIconOrWidget("leading"),
title: title,
subtitle: subtitle,
trailing: trailing,
children: controls,
subtitle: widget.control.buildTextOrWidget("subtitle"),
trailing: widget.control.buildIconOrWidget("trailing"),
children: widget.control.buildWidgets("controls"),
);

return LayoutControl(control: control, child: tile);
return LayoutControl(control: widget.control, child: tile);
}
}
8 changes: 8 additions & 0 deletions packages/flet/lib/src/utils/theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import '../flet_backend.dart';
import '../models/control.dart';
import '../utils/transforms.dart';
import 'alignment.dart';
import 'animations.dart';
import 'borders.dart';
import 'box.dart';
import 'buttons.dart';
Expand Down Expand Up @@ -850,6 +851,9 @@ ListTileThemeData? parseListTileTheme(
parseTextStyle(value["leading_and_trailing_text_style"], theme),
mouseCursor: parseWidgetStateMouseCursor(value["mouse_cursor"]),
minTileHeight: parseDouble(value["min_height"]),
controlAffinity: parseListTileControlAffinity(value["affinity"]),
style: parseListTileStyle(value["style"]),
titleAlignment: parseListTileTitleAlignment(value["title_alignment"]),
);
}

Expand Down Expand Up @@ -894,6 +898,10 @@ ExpansionTileThemeData? parseExpansionTileTheme(
tilePadding: parsePadding(value["tile_padding"]),
expandedAlignment: parseAlignment(value["expanded_alignment"]),
childrenPadding: parsePadding(value["controls_padding"]),
shape: parseShape(value["shape"], theme),
collapsedShape: parseShape(value["collapsed_shape"], theme),
expansionAnimationStyle:
parseAnimationStyle(value["expansion_animation_style"]),
);
}

Expand Down
44 changes: 44 additions & 0 deletions sdk/python/examples/controls/expansion_tile/custom_animations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import flet as ft


def main(page: ft.Page):
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
page.spacing = 20

def switch_animation(e: ft.Event[ft.CupertinoSlidingSegmentedButton]):
if e.control.selected_index == 0:
tile.animation_style = None
elif e.control.selected_index == 1:
tile.animation_style = ft.AnimationStyle(
curve=ft.AnimationCurve.BOUNCE_OUT,
duration=ft.Duration(seconds=5),
)
else:
tile.animation_style = ft.AnimationStyle.no_animation()

page.add(
ft.CupertinoSlidingSegmentedButton(
selected_index=0,
thumb_color=ft.Colors.BLUE_400,
on_change=switch_animation,
controls=[
ft.Text("Default animation"),
ft.Text("Custom animation"),
ft.Text("No animation"),
],
),
tile := ft.ExpansionTile(
expanded=True,
title=ft.Text(
"Expand/Collapse me while being attentive to the animations!"
),
controls=[
ft.ListTile(title=ft.Text("Sub-item 1")),
ft.ListTile(title=ft.Text("Sub-item 2")),
ft.ListTile(title=ft.Text("Sub-item 3")),
],
),
)


ft.run(main)
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import flet as ft


def main(page: ft.Page):
page.spacing = 20

def expand_tile(e: ft.Event[ft.FilledButton]):
tile.expanded = True

def collapse_tile(e: ft.Event[ft.OutlinedButton]):
tile.expanded = False

page.add(
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ft.FilledButton("Expand Tile", on_click=expand_tile),
ft.OutlinedButton("Collapse Tile", on_click=collapse_tile),
],
),
tile := ft.ExpansionTile(
title=ft.Text("I am the title of this tile.", weight=ft.FontWeight.BOLD),
subtitle=ft.Text("This is the subtitle."),
affinity=ft.TileAffinity.LEADING,
controls=[ft.Text("👻", size=80)],
expanded=True,
on_change=lambda e: print(
f"Tile was {'expanded' if e.data else 'collapsed'}"
),
),
)


ft.run(main)
11 changes: 11 additions & 0 deletions sdk/python/packages/flet/docs/controls/expansiontile.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ example_media: ../examples/controls/expansion_tile/media

{{ image(example_media + "/basic.png", alt="basic", width="80%") }}

## Programmatic expansion/collapse

```python
--8<-- "{{ examples }}/programmatic_expansion.py"
```

## Custom animations

```python
--8<-- "{{ examples }}/custom_animations.py"
```

### Theme mode toggle

Expand Down
20 changes: 20 additions & 0 deletions sdk/python/packages/flet/src/flet/controls/animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ def copy(

@dataclass
class AnimationStyle:
"""
Used to override the default parameters of an animation.

Note:
If [`duration`][(c).] and [`reverse_duration`][(c).] are set to
[`Duration()`][flet.Duration], the corresponding animation will be disabled.
See [`no_animation()`][(c).no_animation] method for a convenient way to create
such an instance.
"""

duration: Optional[DurationValue] = None
"""
The duration of the animation.
Expand All @@ -106,6 +116,16 @@ class AnimationStyle:
The curve to use for the reverse animation.
"""

@staticmethod
def no_animation() -> "AnimationStyle":
"""
Creates an instance of `AnimationStyle` with no animation.
"""
return AnimationStyle(
duration=Duration(),
reverse_duration=Duration(),
)

def copy(
self,
*,
Expand Down
Loading
Loading