maxim-saplin / data_table_2

In-place substitute for Flutter's DataTable and PaginatedDataTable with fixed/sticky header and extra features
https://pub.dev/packages/data_table_2
BSD 3-Clause "New" or "Revised" License
202 stars 135 forks source link

How do I make DataRow2 react to Theme changes? #260

Closed sashkent3 closed 6 months ago

sashkent3 commented 6 months ago

Hi! My Flutter application has a theme brightness toggle and an AsyncPaginatedDataTable2. However, I have trouble updating the rows' colors. If the initial color of a DataRow2 is null then it reacts to theme brightness changes properly. However, if I customize their color depending on the row data, it updates the look only when the .getRows() is called (for example via .refreshDatasource()). For the whole duration of the loading animation custom colored rows look nasty, cause they don't match the theme's brightness. Is there any way to resolve this?

maxim-saplin commented 6 months ago

I don't know. If you provide a minimal repro case I could look at it

sashkent3 commented 6 months ago

Hi @maxim-saplin. Here's a small example. Observe how if you toggle the theme colored rows do not change their color until the table is refreshed (because we need to call getRows to get the updated colors).

import 'package:data_table_2/data_table_2.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  ThemeMode themeMode = ThemeMode.light;

  void toggleThemeMode() {
    setState(() {
      themeMode =
          themeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
    });
  }

  ThemeData lightTheme = ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.deepPurple,
      brightness: Brightness.light,
    ),
    useMaterial3: true,
    brightness: Brightness.light,
  );

  ThemeData darkTheme = ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.deepPurple,
      brightness: Brightness.dark,
    ),
    useMaterial3: true,
    brightness: Brightness.dark,
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: lightTheme,
      darkTheme: darkTheme,
      themeMode: themeMode,
      home: MyHomePage(
        title: 'Flutter Demo Home Page',
        toggleThemeMode: toggleThemeMode,
        darkSurfaceVariant: darkTheme.colorScheme.surfaceVariant,
        lightSurfaceVariant: lightTheme.colorScheme.surfaceVariant,
      ),
    );
  }
}

class NastySource extends AsyncDataTableSource {
  NastySource({
    required this.darkSurfaceVariant,
    required this.lightSurfaceVariant,
  });

  final offlineRows = List.generate(100, (i) => (i * 3, i * 3 + 1, i * 3 + 2));

  bool themeIsDark = false;
  final Color darkSurfaceVariant;
  final Color lightSurfaceVariant;

  @override
  Future<AsyncRowsResponse> getRows(int startIndex, int count) {
    return Future.delayed(
      const Duration(seconds: 3),
      () => AsyncRowsResponse(
        offlineRows.length,
        offlineRows
            .getRange(startIndex, startIndex + count)
            .map(
              (e) => DataRow2(
                cells: [
                  DataCell(Text(e.$1.toString())),
                  DataCell(Text(e.$2.toString())),
                  DataCell(Text(e.$3.toString()))
                ],
                color: e.$2 % 4 == 0
                    ? MaterialStatePropertyAll(
                        themeIsDark ? darkSurfaceVariant : lightSurfaceVariant,
                      )
                    : null,
              ),
            )
            .toList(),
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    super.key,
    required this.title,
    required this.toggleThemeMode,
    required this.darkSurfaceVariant,
    required this.lightSurfaceVariant,
  });

  final String title;
  final void Function() toggleThemeMode;
  final Color darkSurfaceVariant;
  final Color lightSurfaceVariant;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late final NastySource source;

  @override
  void initState() {
    super.initState();
    source = NastySource(
      darkSurfaceVariant: widget.darkSurfaceVariant,
      lightSurfaceVariant: widget.lightSurfaceVariant,
    );
  }

  @override
  Widget build(BuildContext context) {
    source.themeIsDark = Theme.of(context).brightness == Brightness.dark;
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: AsyncPaginatedDataTable2(
          columns: const [
            DataColumn2(label: Text('One')),
            DataColumn2(label: Text('Two')),
            DataColumn2(label: Text('Three')),
          ],
          source: source,
          autoRowsToHeight: true,
          header: const Text('Nasty DataTable2'),
          actions: [
            IconButton(
                onPressed: () => source.refreshDatasource(),
                icon: const Icon(Icons.refresh))
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: widget.toggleThemeMode,
        label: const Text('TOGGLE THEME'),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
    );
  }
}
maxim-saplin commented 6 months ago

You can do this using resolve method of material property:

                color: MaterialStateProperty.resolveWith<Color?>(
                    (Set<MaterialState> states) {
                  if (e.$2 % 4 == 0) {
                    return isDarkTheme
                        ? darkSurfaceVariant
                        : lightSurfaceVariant;
                  }
                  return null; // Return null for rows that do not need specific styling.
                }),

Here's a full example. It uses ugly global var for the theme status, you can do it properly in your code:

import 'package:data_table_2/data_table_2.dart';
import 'package:flutter/material.dart';

bool isDarkTheme = false;

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  ThemeMode themeMode = ThemeMode.light;

  void toggleThemeMode() {
    setState(() {
      themeMode =
          themeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
      isDarkTheme = themeMode == ThemeMode.dark;
    });
  }

  ThemeData lightTheme = ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.deepPurple,
      brightness: Brightness.light,
    ),
    useMaterial3: true,
    brightness: Brightness.light,
  );

  ThemeData darkTheme = ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.deepPurple,
      brightness: Brightness.dark,
    ),
    useMaterial3: true,
    brightness: Brightness.dark,
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: lightTheme,
      darkTheme: darkTheme,
      themeMode: themeMode,
      home: MyHomePage(
        title: 'Flutter Demo Home Page',
        toggleThemeMode: toggleThemeMode,
        darkSurfaceVariant: darkTheme.colorScheme.surfaceVariant,
        lightSurfaceVariant: lightTheme.colorScheme.surfaceVariant,
      ),
    );
  }
}

class NastySource extends AsyncDataTableSource {
  NastySource({
    required this.darkSurfaceVariant,
    required this.lightSurfaceVariant,
  });

  final offlineRows = List.generate(100, (i) => (i * 3, i * 3 + 1, i * 3 + 2));

  bool themeIsDark = false;
  final Color darkSurfaceVariant;
  final Color lightSurfaceVariant;

  @override
  Future<AsyncRowsResponse> getRows(int startIndex, int count) {
    return Future.delayed(
      const Duration(seconds: 3),
      () => AsyncRowsResponse(
        offlineRows.length,
        offlineRows
            .getRange(startIndex, startIndex + count)
            .map(
              (e) => DataRow2(
                cells: [
                  DataCell(Text(e.$1.toString())),
                  DataCell(Text(e.$2.toString())),
                  DataCell(Text(e.$3.toString()))
                ],
                color: MaterialStateProperty.resolveWith<Color?>(
                    (Set<MaterialState> states) {
                  if (e.$2 % 4 == 0) {
                    return isDarkTheme
                        ? darkSurfaceVariant
                        : lightSurfaceVariant;
                  }
                  return null; // Return null for rows that do not need specific styling.
                }),

                // color: e.$2 % 4 == 0
                //     ? MaterialStatePropertyAll(
                //         themeIsDark ? darkSurfaceVariant : lightSurfaceVariant,
                //       )
                //     : null,
              ),
            )
            .toList(),
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    super.key,
    required this.title,
    required this.toggleThemeMode,
    required this.darkSurfaceVariant,
    required this.lightSurfaceVariant,
  });

  final String title;
  final void Function() toggleThemeMode;
  final Color darkSurfaceVariant;
  final Color lightSurfaceVariant;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late final NastySource source;

  @override
  void initState() {
    super.initState();
    source = NastySource(
      darkSurfaceVariant: widget.darkSurfaceVariant,
      lightSurfaceVariant: widget.lightSurfaceVariant,
    );
  }

  @override
  Widget build(BuildContext context) {
    source.themeIsDark = Theme.of(context).brightness == Brightness.dark;
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: AsyncPaginatedDataTable2(
          columns: const [
            DataColumn2(label: Text('One')),
            DataColumn2(label: Text('Two')),
            DataColumn2(label: Text('Three')),
          ],
          source: source,
          autoRowsToHeight: true,
          header: const Text('Nasty DataTable2'),
          actions: [
            IconButton(
                onPressed: () => source.refreshDatasource(),
                icon: const Icon(Icons.refresh))
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: widget.toggleThemeMode,
        label: const Text('TOGGLE THEME'),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
    );
  }
}
sashkent3 commented 6 months ago

Clever solution! I will verify if it works and close the issue later. BTW, any way to acquire BuildContext in .resolveWith()? I would like to use Theme.of(context) directly.

maxim-saplin commented 6 months ago

From the top of my head... You can add context param to data source constructor, create the data source on every build, pass in tte context into data source... In the getRows use the context from the field, also make sure the context is mounted: https://api.flutter.dev/flutter/widgets/BuildContext/mounted.html

sashkent3 commented 6 months ago

Creating a new data source on every build doesn't seem to work for some reason, however this seems to be a good enough solution:

import 'package:data_table_2/data_table_2.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  ThemeMode themeMode = ThemeMode.light;

  void toggleThemeMode() {
    setState(() {
      themeMode =
          themeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
    });
  }

  ThemeData lightTheme = ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.deepPurple,
      brightness: Brightness.light,
    ),
    useMaterial3: true,
    brightness: Brightness.light,
  );

  ThemeData darkTheme = ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.deepPurple,
      brightness: Brightness.dark,
    ),
    useMaterial3: true,
    brightness: Brightness.dark,
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: lightTheme,
      darkTheme: darkTheme,
      themeMode: themeMode,
      home: MyHomePage(
        title: 'Flutter Demo Home Page',
        toggleThemeMode: toggleThemeMode,
      ),
    );
  }
}

class NastySource extends AsyncDataTableSource {
  NastySource();

  final offlineRows = List.generate(100, (i) => (i * 3, i * 3 + 1, i * 3 + 2));

  late Color surfaceVariant;

  @override
  Future<AsyncRowsResponse> getRows(int startIndex, int count) {
    return Future.delayed(
      const Duration(seconds: 3),
      () => AsyncRowsResponse(
        offlineRows.length,
        offlineRows
            .getRange(startIndex, startIndex + count)
            .map(
              (e) => DataRow2(
                cells: [
                  DataCell(Text(e.$1.toString())),
                  DataCell(Text(e.$2.toString())),
                  DataCell(Text(e.$3.toString()))
                ],
                color: e.$2 % 4 == 0
                    ? MaterialStateProperty.resolveWith((_) => surfaceVariant)
                    : null,
              ),
            )
            .toList(),
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    super.key,
    required this.title,
    required this.toggleThemeMode,
  });

  final String title;
  final void Function() toggleThemeMode;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late final NastySource source;

  @override
  void initState() {
    super.initState();
    source = NastySource();
  }

  @override
  Widget build(BuildContext context) {
    source.surfaceVariant = Theme.of(context).colorScheme.surfaceVariant;
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: AsyncPaginatedDataTable2(
          columns: const [
            DataColumn2(label: Text('One')),
            DataColumn2(label: Text('Two')),
            DataColumn2(label: Text('Three')),
          ],
          source: source,
          autoRowsToHeight: true,
          header: const Text('Nasty DataTable2'),
          actions: [
            IconButton(
                onPressed: () => source.refreshDatasource(),
                icon: const Icon(Icons.refresh))
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: widget.toggleThemeMode,
        label: const Text('TOGGLE THEME'),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
    );
  }
}