Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Theme.merge() #148449

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft

Theme.merge() #148449

wants to merge 6 commits into from

Conversation

nate-thegrate
Copy link
Member

@nate-thegrate nate-thegrate commented May 16, 2024

I learned about the upcoming Theme System Updates today, and oh boy I am so pumped. Since we're doing a theme system overhaul, now might be a good time to resolve that issue from 7 years ago!


This pull request adds a merge() method for the Theme widget that implements the logic of the main ThemeData constructor.

// before
final ThemeData themeData = Theme.of(context);
final ButtonThemeData buttonTheme = themeData.buttonTheme;
return Theme(
  data: themeData.copyWith(buttonTheme: buttonTheme.copyWith(shape: newShape)),
  child: child,
);

// after
return Theme.merge(
  data: ButtonThemeData(shape: newShape),
  child: child,
);

Theme.merge() accepts a nice variety of arguments, including:

  • ThemeData
  • InputDecorationTheme, ButtonThemeData, and all of the other standard theme classes
  • Any ThemeExtension subclass
  • Brightness.dark works too!

An inordinate amount of boilerplate has been added in order to maintain backward compatibility. Maybe instead of looking through the diffs, we can all take a look at this beautiful demo!

Demo source code
import 'package:flutter/material.dart';

void main() {
  final Widget materialApp = MaterialApp(
    theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple)),
    debugShowCheckedModeBanner: false,
    home: const HomePage(),
  );

  runApp(materialApp);
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

enum ThemeCycle {
  basic,
  dark,
  green,
  darkCyan,
  blueBeveled,
  scarletStadium,
  continuousChartreuse,
  blackBar;

  Object get data {
    return switch (this) {
      basic => ThemeData(),
      dark => Brightness.dark,
      green => ColorScheme.fromSeed(seedColor: Colors.green),
      darkCyan => ColorScheme.fromSeed(
          seedColor: const Color(0xFF00FFFF),
          brightness: Brightness.dark,
        ),
      blueBeveled => ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
          buttonTheme: const ButtonThemeData(
            shape: BeveledRectangleBorder(
              borderRadius: BorderRadius.all(Radius.circular(10)),
            ),
          ),
        ),
      scarletStadium => ThemeData(
          colorScheme: ColorScheme.fromSeed(
            seedColor: const Color(0xFFC00010),
            brightness: Brightness.dark,
          ),
          buttonTheme: const ButtonThemeData(shape: StadiumBorder()),
        ),
      continuousChartreuse => ThemeData(
          colorScheme: ColorScheme.fromSeed(
            seedColor: const Color(0xFF80FF00),
            brightness: Brightness.dark,
          ),
          buttonTheme: ButtonThemeData(
            shape: ContinuousRectangleBorder(
              borderRadius: BorderRadius.circular(42),
            ),
          ),
        ),
      blackBar => ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.black),
          appBarTheme: const AppBarTheme(
            backgroundColor: Colors.black,
            foregroundColor: Colors.white,
            titleTextStyle: TextStyle(fontWeight: FontWeight.w100, fontSize: 24),
          ),
          textTheme: const TextTheme(labelLarge: TextStyle(fontWeight: FontWeight.w100)),
        ),
    };
  }

  // convert camelCase to multiple words
  @override
  String toString() {
    final characters = name.characters.map(
      (c) => switch (c.toLowerCase()) {
        final lowered when lowered != c => ' $lowered',
        _ => c,
      },
    );
    return characters.join();
  }
}

class _HomePageState extends State<HomePage> {
  ThemeCycle themeCycle = ThemeCycle.basic;
  DynamicSchemeVariant dynamicSchemeVariant = DynamicSchemeVariant.tonalSpot;

  @override
  Widget build(BuildContext context) {
    final List<CycleButton> cycleButtons = [
      CycleButton(
        label: 'preset:  $themeCycle',
        onPressed: () {
          const values = ThemeCycle.values;
          final next = values[(themeCycle.index + 1) % values.length];
          setState(() {
            themeCycle = next;
          });
        },
      ),
      CycleButton(
        label: 'variant:  "${dynamicSchemeVariant.name}"',
        onPressed: () {
          const variants = DynamicSchemeVariant.values;
          final next = variants[(dynamicSchemeVariant.index + 1) % variants.length];
          setState(() {
            dynamicSchemeVariant = next;
          });
        },
      ),
    ];

    final Object themeData = themeCycle.data;

    return Theme.merge(
      data: dynamicSchemeVariant,
      child: Theme.merge(
        data: themeData,
        child: Builder(
          builder: (context) => AnimatedTheme(
            duration: Durations.medium1,
            curve: Curves.easeOutSine,
            data: Theme.of(context),
            child: Scaffold(
              appBar: AppBar(
                title: const Text('Theme.merge()'),
              ),
              body: ThemeDemo(cycleButtons),
            ),
          ),
        ),
      ),
    );
  }
}

class CycleButton extends StatelessWidget {
  const CycleButton({required this.onPressed, required this.label, super.key});
  final VoidCallback onPressed;
  final String label;

  @override
  Widget build(BuildContext context) {
    const style = TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600);
    return Expanded(
      child: Padding(
        padding: const EdgeInsets.fromLTRB(6, 12, 6, 0),
        child: Material(
          color: Colors.black,
          borderRadius: const BorderRadius.only(
            topLeft: Radius.circular(16),
            topRight: Radius.circular(16),
          ),
          clipBehavior: Clip.antiAlias,
          child: InkWell(
            overlayColor: const WidgetStatePropertyAll(Colors.white12),
            onTap: onPressed,
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: Center(
                child: Text(label, style: style, textAlign: TextAlign.center),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class ThemeDemo extends StatelessWidget {
  const ThemeDemo(this.cycleButtons, {super.key});
  final List<CycleButton> cycleButtons;

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    final ColorScheme colorScheme = theme.colorScheme;
    final List<(Color, Color, String)> samples = [
      (colorScheme.primary, colorScheme.onPrimary, 'primary'),
      (colorScheme.primaryContainer, colorScheme.onPrimaryContainer, 'primary container'),
      (colorScheme.secondary, colorScheme.onSecondary, 'secondary'),
      (colorScheme.secondaryContainer, colorScheme.onSecondaryContainer, 'secondary container'),
      (colorScheme.tertiary, colorScheme.onTertiary, 'tertiary'),
      (colorScheme.tertiaryContainer, colorScheme.onTertiaryContainer, 'tertiary container'),
      (colorScheme.surfaceContainerLowest, colorScheme.onSurface, 'surface container (lowest)'),
      (colorScheme.surfaceContainerHighest, colorScheme.onSurface, 'surface container (highest)'),
      (colorScheme.inverseSurface, colorScheme.onInverseSurface, 'inverse surface'),
    ];

    return Column(
      children: [
        for (final (background, foreground, label) in samples)
          Expanded(
            child: Container(
              margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
              decoration: ShapeDecoration(
                shape: theme.buttonTheme.shape,
                color: background,
              ),
              alignment: Alignment.center,
              child: Text(
                label,
                style: theme.textTheme.labelLarge!.copyWith(
                  color: foreground,
                  fontSize: 16,
                ),
                textAlign: TextAlign.center,
              ),
            ),
          ),
        Expanded(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 6),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: cycleButtons,
            ),
          ),
        ),
      ],
    );
  }
}
Demo

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@github-actions github-actions bot added framework flutter/packages/flutter repository. See also f: labels. f: material design flutter/packages/flutter/material repository. labels May 16, 2024
@Piinks
Copy link
Contributor

Piinks commented May 22, 2024

Hey @nate-thegrate! Since this is a draft and failing some tests, we'll likely hold off on review until you give the word. Let us know when you are ready for some feedback! :)

@nate-thegrate
Copy link
Member Author

Hey @nate-thegrate! Since this is a draft and failing some tests, we'll likely hold off on review until you give the word. Let us know when you are ready for some feedback! :)

Sounds great! I'm going to try fixing a few bugs I introduced (and maybe also mitigate the $\textcolor{green}{\textsf{+1,447 }}\textcolor{#c00000}{\textsf{−374}}$ diffs with an extension type or something). I'll "un-draft" the PR once that's done, which will hopefully be soon!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add a Theme.merge that implements the logic of the main ThemeData constructor
2 participants