new home screen design

This commit is contained in:
austinried 2025-10-18 15:06:43 +09:00
parent d59b2afe37
commit 9f98304e0a
6 changed files with 475 additions and 7 deletions

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'router.dart';
void main() {
runApp(const MainApp());
}
@ -9,12 +11,11 @@ class MainApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: Text('Hello World!'),
),
),
return MaterialApp.router(
themeMode: ThemeMode.dark,
darkTheme: ThemeData.dark(useMaterial3: true),
debugShowCheckedModeBanner: false,
routerConfig: router,
);
}
}

11
lib/router.dart Normal file
View File

@ -0,0 +1,11 @@
import 'package:go_router/go_router.dart';
import 'package:subtracks/screens/home.dart';
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => HomeScreen(),
),
],
);

218
lib/screens/home.dart Normal file
View File

@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../util/custom_scroll_fix.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
late final TabController tabController;
final iconSize = 26.0;
final tabHeight = 32.0;
late final List<(String, Widget)> tabs = [
("Home", Icon(Symbols.home_rounded, size: iconSize)),
("Albums", Icon(Symbols.album_rounded, size: iconSize)),
("Artists", Icon(Symbols.person_rounded, size: iconSize)),
("Songs", Icon(Symbols.music_note_rounded, size: iconSize)),
("Playlists", Icon(Symbols.playlist_play_rounded, size: iconSize)),
];
@override
void initState() {
super.initState();
tabController = TabController(
length: tabs.length,
initialIndex: 1,
vsync: this,
);
}
@override
void dispose() {
tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IconTheme(
data: IconThemeData(
fill: 1,
color: TextTheme.of(context).headlineLarge?.color,
weight: 600,
opticalSize: iconSize,
),
child: Scaffold(
body: NestedScrollView(
floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) {
return <Widget>[
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
context,
),
sliver: SliverAppBar(
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 18,
vertical: 16,
),
child: Text(
"Albums",
style: TextTheme.of(context).headlineLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
),
),
pinned: true,
floating: true,
bottom: PreferredSize(
preferredSize: Size.fromHeight(tabHeight + 18),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TabBar(
controller: tabController,
dividerColor: Colors.transparent,
isScrollable: true,
tabAlignment: TabAlignment.start,
indicatorSize: TabBarIndicatorSize.label,
labelPadding: EdgeInsets.symmetric(
horizontal: 2,
),
labelColor: Theme.of(context).primaryColorDark,
unselectedLabelColor: Theme.of(
context,
).textTheme.headlineLarge?.color,
padding: EdgeInsets.symmetric(
// horizontal: 12,
vertical: 8,
),
splashBorderRadius: BorderRadius.circular(8),
indicator: BoxDecoration(
color: Theme.of(
context,
).primaryTextTheme.headlineLarge?.color,
borderRadius: BorderRadius.circular(8),
),
tabs: tabs
.map(
(tab) => Tab(
height: tabHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
),
child: tab.$2,
),
),
)
.toList(),
),
IconButton(
onPressed: () {},
icon: Icon(
Symbols.settings_rounded,
),
),
],
),
),
),
),
),
];
},
body: Builder(
builder: (context) {
return CustomScrollProvider(
tabController: tabController,
parent: PrimaryScrollController.of(context),
child: TabBarView(
// These are the contents of the tab views, below the tabs.
controller: tabController,
children: tabs.map((tab) {
final index = tabs.indexOf(tab);
return SafeArea(
top: false,
bottom: false,
child: NewWidget(index: index, tab: tab),
);
}).toList(),
),
);
},
),
),
),
);
}
}
class NewWidget extends StatefulWidget {
const NewWidget({
super.key,
required this.index,
required this.tab,
});
final int index;
final (String, Widget) tab;
@override
State<NewWidget> createState() => _NewWidgetState();
}
class _NewWidgetState extends State<NewWidget>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
final scrollProvider = CustomScrollProviderData.of(context);
return CustomScrollView(
controller: scrollProvider.scrollControllers[widget.index],
slivers: <Widget>[
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
context,
),
),
SliverPadding(
padding: const EdgeInsets.all(8.0),
sliver: SliverFixedExtentList(
itemExtent: 48.0,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(
title: Text('Item $index'),
onTap: () {},
);
},
childCount: 30,
),
),
),
],
);
}
}

View File

@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
// Fixes scroll postion being reset in a tab when scrolling up inside a
// NestedScrollView.
//
// https://dartpad.dev/?id=5d1a0c7c4b55281ccff377fc8ea90b60
// https://github.com/flutter/flutter/issues/159123#issuecomment-2614343654
class CustomScrollProvider extends StatefulWidget {
const CustomScrollProvider({
super.key,
required this.tabController,
required this.parent,
required this.child,
});
final TabController tabController;
final ScrollController parent;
final Widget child;
@override
State<CustomScrollProvider> createState() => _CustomScrollProviderState();
}
class _CustomScrollProviderState extends State<CustomScrollProvider> {
late final List<CustomScrollController> scrollControllers;
@override
void initState() {
super.initState();
final activeIndex = widget.tabController.index;
scrollControllers = List.generate(
widget.tabController.length,
(index) => CustomScrollController(
isActive: index == activeIndex,
parent: widget.parent,
debugLabel: 'CustomScrollController/$index',
),
);
widget.tabController.addListener(() {
changeActiveIndex(widget.tabController.index);
});
}
@override
void dispose() {
for (final scrollController in scrollControllers) {
scrollController.dispose();
}
super.dispose();
}
void changeActiveIndex(int value) {
for (var i = 0; i < scrollControllers.length; i++) {
final scrollController = scrollControllers[i];
final isActive = i == value;
scrollController.isActive = isActive;
if (isActive) {
scrollController.forceAttach();
} else {
scrollController.forceDetach();
}
}
}
@override
Widget build(BuildContext context) {
return CustomScrollProviderData(
scrollControllers: scrollControllers,
child: widget.child,
);
}
}
class CustomScrollProviderData extends InheritedWidget {
const CustomScrollProviderData({
super.key,
required super.child,
required this.scrollControllers,
});
static CustomScrollProviderData of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<CustomScrollProviderData>()!;
}
final List<CustomScrollController> scrollControllers;
@override
bool updateShouldNotify(CustomScrollProviderData oldWidget) {
return scrollControllers != oldWidget.scrollControllers;
}
}
class CustomScrollController extends ScrollController {
CustomScrollController({
required this.isActive,
required this.parent,
String debugLabel = 'CustomScrollController',
}) : super(
debugLabel: parent.debugLabel == null
? null
: '${parent.debugLabel}/$debugLabel',
initialScrollOffset: parent.initialScrollOffset,
keepScrollOffset: parent.keepScrollOffset,
);
bool isActive;
final ScrollController parent;
@override
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition? oldPosition,
) {
debugPrint('$debugLabel-createScrollPosition: $isActive');
return parent.createScrollPosition(physics, context, oldPosition);
}
@override
void attach(ScrollPosition position) {
debugPrint('$debugLabel-attach: $isActive');
super.attach(position);
if (isActive && !parent.positions.contains(position)) {
parent.attach(position);
}
}
@override
void detach(ScrollPosition position) {
debugPrint('$debugLabel-detach: $isActive');
if (parent.positions.contains(position)) {
parent.detach(position);
}
super.detach(position);
}
void forceDetach() {
debugPrint('$debugLabel-forceDetach: $isActive');
for (final position in positions) {
if (parent.positions.contains(position)) {
parent.detach(position);
}
}
}
void forceAttach() {
debugPrint('$debugLabel-forceAttach: $isActive');
for (final position in positions) {
if (!parent.positions.contains(position)) {
parent.attach(position);
}
}
}
@override
void dispose() {
debugPrint('$debugLabel-dispose: $isActive');
forceDetach();
super.dispose();
}
}

View File

@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@ -17,6 +25,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
chalkdart:
dependency: transitive
description:
name: chalkdart
sha256: "7ffc6bd39c81453fb9ba8dbce042a9c960219b75ea1c07196a7fa41c2fab9e86"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
characters:
dependency: transitive
description:
@ -49,6 +65,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
@ -67,6 +91,27 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: e1d7ffb0db475e6e845eb58b44768f50b830e23960e3df6908924acd8f7f70ea
url: "https://pub.dev"
source: hosted
version: "16.2.5"
leak_tracker:
dependency: transitive
description:
@ -99,6 +144,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@ -115,6 +168,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.11.1"
material_symbols_icons:
dependency: "direct main"
description:
name: material_symbols_icons
sha256: "9a7de58ffc299c8e362b4e860e36e1d198fa0981a894376fe1b6bfe52773e15b"
url: "https://pub.dev"
source: hosted
version: "4.2874.0"
meta:
dependency: transitive
description:
@ -202,4 +263,4 @@ packages:
version: "15.0.2"
sdks:
dart: ">=3.9.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.29.0"

View File

@ -9,6 +9,8 @@ environment:
dependencies:
flutter:
sdk: flutter
go_router: ^16.2.5
material_symbols_icons: ^4.2874.0
dev_dependencies:
flutter_test: