この記事はFlutter Advent Calendarの7日目の記事です。
突然ですが皆さんはなぜFlutterを使っているのでしょうか?
さまざまな理由があると思いますが「開発時間を短縮できる」という点が大きいのではないでしょうか。
今回は「開発時間の節約」を目的として、ログイン画面のテンプレートを複数用意しました。
ログイン画面はユーザーが使用する場面はとても少ないですが、ほとんどのアプリにおいて必要です。
この部分に当てる時間を削り、アプリの本質的な価値を生み出す部分に時間を使いましょう!
シンプル
TextFormFieldがあるだけのシンプルな画面です。これをベースに作成します。
サンプルコード
import 'package:flutter/material.dart';
class SimpleLoginScreen extends StatefulWidget {
const SimpleLoginScreen({Key? key}) : super(key: key);
@override
_SimpleLoginScreenState createState() => _SimpleLoginScreenState();
}
class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
String email = '';
String password = '';
bool hidePassword = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Simple Login Screen'),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Login',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
TextFormField(
decoration: const InputDecoration(
icon: Icon(Icons.mail),
hintText: 'hogehoge@qmail.com',
labelText: 'Email Address',
),
onChanged: (String value) {
setState(() {
email = value;
});
},
),
TextFormField(
obscureText: hidePassword,
decoration: InputDecoration(
icon: const Icon(Icons.lock),
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(
hidePassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () {
setState(() {
hidePassword = !hidePassword;
});
},
),
),
onChanged: (String value) {
setState(() {
password = value;
});
},
),
const SizedBox(height: 15),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Login'),
)
],
),
),
),
);
}
}
Lottie
画面上部にアニメーションを追加した画面です。
画面に少し動きがあるだけで見栄えがいいですね!
Lottie Filesでアニメーションファイルを探すことができます。
好みのアニメーションに切り替えて使用してください。
サンプルコード
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
// Lottieを使ったログインスクリーン
// アニメーション素材は以下のサイトから取得できる。
// https://lottiefiles.com/
// pubspec.yamlに以下を追加
// lottie: ^1.2.1
class LottieScreen extends StatefulWidget {
const LottieScreen({Key? key}) : super(key: key);
@override
_LottieScreenState createState() => _LottieScreenState();
}
class _LottieScreenState extends State<LottieScreen> {
String email = '';
String password = '';
bool hidePassword = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Lottie Screen'),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
child: Center(
child: Column(
children: [
const Text(
'Login',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
// https://lottiefiles.com/38435-register を使用。
// ページ内の'Lottie Animation URL'で取得したURLを貼り付ける
Lottie.network(
'https://assets9.lottiefiles.com/packages/lf20_jcikwtux.json',
errorBuilder: (context, error, stackTrace) {
return const Padding(
padding: EdgeInsets.all(30.0),
child: CircularProgressIndicator(),
);
},
),
TextFormField(
decoration: const InputDecoration(
icon: Icon(Icons.mail),
hintText: 'hogehoge@qmail.com',
labelText: 'Email Address',
),
onChanged: (String value) {
setState(() {
email = value;
});
},
),
TextFormField(
obscureText: hidePassword,
decoration: InputDecoration(
icon: const Icon(Icons.lock),
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(
hidePassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () {
setState(() {
hidePassword = !hidePassword;
});
},
),
),
onChanged: (String value) {
setState(() {
password = value;
});
},
),
const SizedBox(height: 15),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Login'),
)
],
),
),
),
);
}
}
Nuemorphic
ニューモーフィックデザインを取り入れたログイン画面です。
ニューモーフィズムとは、New + Skeumophism の造語です。Skeumorphismについてはこちらをご覧ください。(Wiki) 「今年こそ流行る」と言われ続けて何年経ったのでしょうか....
実装にはflutter_neumorphicを使用しました。
全体的にニューモーフィックデザインを取り入れているアプリでないと使うことは難しいと思いますが、一例として紹介しておきます。
サンプルコード
import 'package:flutter/material.dart';
import 'package:flutter_neumorphic/flutter_neumorphic.dart';
// pubspec.yamlに以下を追加
// flutter_neumorphic: ^3.2.0
class NeumorphicScreen extends StatefulWidget {
const NeumorphicScreen({Key? key}) : super(key: key);
@override
_NeumorphicScreenState createState() => _NeumorphicScreenState();
}
class _NeumorphicScreenState extends State<NeumorphicScreen> {
String email = '';
String password = '';
bool hidePassword = true;
@override
Widget build(BuildContext context) {
return NeumorphicTheme(
theme: const NeumorphicThemeData(
baseColor: Color(0xffFFFFFF),
lightSource: LightSource.topLeft,
depth: 4,
intensity: 0.7,
),
child: Scaffold(
appBar: NeumorphicAppBar(
title: NeumorphicText(
'Neumorphic screen',
style: const NeumorphicStyle(
depth: 14,
color: Colors.black38,
),
textStyle: NeumorphicTextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
buttonStyle: NeumorphicStyle(
shape: NeumorphicShape.convex,
boxShape: NeumorphicBoxShape.roundRect(BorderRadius.circular(12)),
depth: 6,
lightSource: LightSource.topLeft,
),
),
backgroundColor: NeumorphicTheme.baseColor(context),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
NeumorphicText(
'Login',
style: const NeumorphicStyle(
depth: 14,
color: Colors.black38,
),
textStyle: NeumorphicTextStyle(
fontSize: 40,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 25),
Neumorphic(
style: NeumorphicStyle(
boxShape: NeumorphicBoxShape.roundRect(
BorderRadius.circular(12),
),
depth: -6,
lightSource: LightSource.topLeft,
),
padding: const EdgeInsets.symmetric(horizontal: 10),
child: TextFormField(
decoration: const InputDecoration(
icon: Icon(Icons.mail),
hintText: 'hogehoge@qmail.com',
),
onChanged: (String value) {
setState(() {
email = value;
});
},
),
),
const SizedBox(height: 25),
Neumorphic(
style: NeumorphicStyle(
boxShape: NeumorphicBoxShape.roundRect(
BorderRadius.circular(12),
),
depth: -6,
lightSource: LightSource.topLeft,
),
padding: const EdgeInsets.symmetric(horizontal: 10),
child: TextFormField(
obscureText: hidePassword,
decoration: InputDecoration(
icon: const Icon(Icons.lock),
hintText: 'your password',
suffixIcon: IconButton(
icon: Icon(
hidePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
hidePassword = !hidePassword;
});
},
),
),
onChanged: (String value) {
setState(() {
password = value;
});
},
),
),
const SizedBox(height: 25),
NeumorphicButton(
style: NeumorphicStyle(
shape: NeumorphicShape.convex,
boxShape:
NeumorphicBoxShape.roundRect(BorderRadius.circular(12)),
depth: 6,
lightSource: LightSource.topLeft,
),
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Login'),
),
],
),
),
),
),
);
}
}
image + shader
画像にshaderを使用した例です。画像のパスは書き換えてください。
サンプルコード
class ImageScreen extends StatefulWidget {
const ImageScreen({Key? key}) : super(key: key);
@override
_ImageScreenState createState() => _ImageScreenState();
}
class _ImageScreenState extends State<ImageScreen> {
String email = '';
String password = '';
bool hidePassword = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
title: const Text(
'image screen',
style: TextStyle(color: Colors.white),
),
),
extendBodyBehindAppBar: true,
body: Stack(
children: [
ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (Rect bounds) {
return const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[Colors.black, Colors.black12],
).createShader(bounds);
},
child: SizedBox(
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
child: Image.asset(
'assets/images/background1.jpg',
fit: BoxFit.fill,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.white60.withOpacity(0.2),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Login',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Container(
padding:
const EdgeInsets.only(bottom: 6, left: 12, right: 12),
margin: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: TextFormField(
decoration: const InputDecoration(
icon: Icon(Icons.mail),
hintText: 'hogehoge@qmail.com',
labelText: 'Email Address',
),
onChanged: (String value) {
setState(() {
email = value;
});
},
),
),
Container(
padding:
const EdgeInsets.only(bottom: 6, left: 12, right: 12),
margin: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: TextFormField(
obscureText: hidePassword,
decoration: InputDecoration(
icon: const Icon(Icons.lock),
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(
hidePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
hidePassword = !hidePassword;
});
},
),
),
onChanged: (String value) {
setState(() {
password = value;
});
},
),
),
const SizedBox(height: 15),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Login'),
)
],
),
),
)
],
),
);
}
}
Animation1
ランダムな大きさの円がバックグラウンドで動きます。
CustomPainterを用いて実現しています。
サンプルコード
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
class Animation1 extends StatefulWidget {
const Animation1({Key? key}) : super(key: key);
@override
_Animation1State createState() => _Animation1State();
}
class _Animation1State extends State<Animation1> {
String email = '';
String password = '';
bool hidePassword = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Simple Login Screen'),
),
body: BackgroundAnimation1(
size: MediaQuery.of(context).size,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Login',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
TextFormField(
decoration: const InputDecoration(
icon: Icon(Icons.mail),
hintText: 'hogehoge@qmail.com',
labelText: 'Email Address',
),
onChanged: (String value) {
setState(() {
email = value;
});
},
),
TextFormField(
obscureText: hidePassword,
decoration: InputDecoration(
icon: const Icon(Icons.lock),
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(
hidePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
hidePassword = !hidePassword;
});
},
),
),
onChanged: (String value) {
setState(() {
password = value;
});
},
),
const SizedBox(height: 15),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Login'),
)
],
),
),
),
));
}
}
class BackgroundAnimation1 extends StatefulWidget {
const BackgroundAnimation1({
Key? key,
required this.size,
required this.child,
}) : super(key: key);
final Size size;
final Widget child;
@override
_BackgroundAnimation1State createState() => _BackgroundAnimation1State();
}
class _BackgroundAnimation1State extends State<BackgroundAnimation1> {
late Timer timer;
late List<Particle> particles =
List<Particle>.generate(90, (index) => Particle(size: widget.size));
@override
void initState() {
super.initState();
const duration = Duration(milliseconds: 1000 ~/ 60);
timer = Timer.periodic(duration, (timer) {
setState(() {
for (var element in particles) {
element.moveParticle();
}
});
});
}
@override
void dispose() {
super.dispose();
timer.cancel();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Stack(
children: [
CustomPaint(
size: size,
painter: AnimationPainter1(particles),
),
Center(
child: SizedBox(
height: size.height * 0.4,
child: Card(
color: Colors.white.withOpacity(0.9),
child: widget.child,
),
),
),
],
);
}
}
class AnimationPainter1 extends CustomPainter {
List<Particle> particles;
AnimationPainter1(this.particles);
@override
void paint(Canvas canvas, Size size) {
for (var e in particles) {
canvas.drawCircle(
e.pos,
e.radius,
Paint()
..style = PaintingStyle.fill
..color = e.color,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
class Particle {
final Color color = getRandomColor();
final double radius = getRandomVal(1, 5) * 9;
double dx = getRandomVal(-0.25, 0.25);
double dy = getRandomVal(-0.25, 0.25);
late Size size;
late double x = getRandomVal(0, size.width);
late double y = getRandomVal(0, size.height);
late Offset pos = Offset(x, y); // 現在の点の位置
Particle({required this.size});
void moveParticle() {
Offset nextPos = pos + Offset(dx, dy);
if (nextPos.dx < 0 ||
size.width < nextPos.dx ||
nextPos.dy < 0 ||
size.height < nextPos.dy) {
dx = -dx;
dy = -dy;
nextPos = pos + Offset(dx, dy);
}
pos = nextPos;
}
static Color getRandomColor() {
final colorList = [
Colors.red,
Colors.blueAccent,
Colors.orange,
Colors.yellow,
];
final rnd = Random();
int index = rnd.nextInt(colorList.length);
return colorList[index].withOpacity(0.25);
}
// min ~ max内のランダムな値を取得
static double getRandomVal(double min, double max) {
final rnd = Random();
return rnd.nextDouble() * (max - min) + min;
}
}
Animation2
波打つようなエフェクトが特徴的な画面です。先ほど同様、CustomPainterを使って実現しています。この動画を参考にさせて頂きました。
動画内ではCustomClipperを使っていますが動作はほぼ同じです。
サンプルコード
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
class Animation2 extends StatefulWidget {
const Animation2({Key? key}) : super(key: key);
@override
_Animation2State createState() => _Animation2State();
}
class _Animation2State extends State<Animation2> {
String email = '';
String password = '';
bool hidePassword = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
title: const Text('Simple Login Screen'),
),
extendBodyBehindAppBar: true,
body: BackgroundAnimation2(
size: MediaQuery.of(context).size,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Login',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
TextFormField(
decoration: const InputDecoration(
icon: Icon(Icons.mail),
hintText: 'hogehoge@qmail.com',
labelText: 'Email Address',
),
onChanged: (String value) {
setState(() {
email = value;
});
},
),
TextFormField(
obscureText: hidePassword,
decoration: InputDecoration(
icon: const Icon(Icons.lock),
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(
hidePassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () {
setState(() {
hidePassword = !hidePassword;
});
},
),
),
onChanged: (String value) {
setState(() {
password = value;
});
},
),
const SizedBox(height: 15),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Login'),
)
],
),
),
),
),
);
}
}
class BackgroundAnimation2 extends StatefulWidget {
const BackgroundAnimation2({
Key? key,
required this.size,
required this.child,
}) : super(key: key);
final Size size;
final Widget child;
@override
_BackgroundAnimation2State createState() => _BackgroundAnimation2State();
}
class _BackgroundAnimation2State extends State<BackgroundAnimation2> {
late Timer timer;
double time = 0;
@override
void initState() {
super.initState();
const duration = Duration(milliseconds: 1000 ~/ 60); // 60fps
timer = Timer.periodic(duration, (timer) {
setState(() {
time += 0.0025;
});
});
}
@override
void dispose() {
super.dispose();
timer.cancel();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Stack(
children: [
CustomPaint(
size: size,
painter: AnimationPainter2(
waveColor: Colors.blueAccent.withOpacity(0.8),
height: 0.25,
time: time,
),
),
Center(
child: widget.child,
),
],
);
}
}
class AnimationPainter2 extends CustomPainter {
double height;
Color waveColor;
double time;
AnimationPainter2({
required this.waveColor,
required this.height,
required this.time,
});
@override
void paint(Canvas canvas, Size size) {
Path path = Path();
final double waveSpeed = time * 1080;
final double fullSphere = time * pi * 2;
final double normalizer = cos(fullSphere);
final double waveWidth = pi / 270;
const double waveHeight = 35.0;
path.lineTo(0, size.height * height);
for (int i = 0; i < size.width.toInt(); i++) {
double calc = sin((waveSpeed - i) * waveWidth);
path.lineTo(
i.toDouble(),
size.height * height + calc * waveHeight * normalizer,
);
}
path.lineTo(size.width, 0);
Paint wavePaint = Paint()..color = waveColor;
canvas.drawPath(path, wavePaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
flutter_login
FlutterLoginは既製のログイン/サインアップウィジェットです。
下のgifはほぼサンプルコードと同じなので、Readmeをご覧ください。
実装も簡単なのでオススメです。
サンプルコード
import 'package:flutter/material.dart';
import 'package:flutter_login/flutter_login.dart';
const users = {
'dribbble@gmail.com': '12345',
'hunter@gmail.com': 'hunter',
};
// pubspec.yamlに以下を追加
// flutter_login: ^3.0.0
class LoginScreen extends StatelessWidget {
const LoginScreen({Key? key}) : super(key: key);
Duration get loginTime => const Duration(milliseconds: 2250);
Future<String?> _authUser(LoginData data) {
debugPrint('Name: ${data.name}, Password: ${data.password}');
return Future.delayed(loginTime).then((_) {
if (!users.containsKey(data.name)) {
return 'User not exists';
}
if (users[data.name] != data.password) {
return 'Password does not match';
}
return null;
});
}
Future<String?> _signupUser(SignupData data) {
debugPrint('Signup Name: ${data.name}, Password: ${data.password}');
return Future.delayed(loginTime).then((_) {
return null;
});
}
Future<String?> _recoverPassword(String name) {
debugPrint('Name: $name');
return Future.delayed(loginTime).then((_) {
if (!users.containsKey(name)) {
return 'User not exists';
}
return null;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: FlutterLogin(
title: 'ECORP',
onLogin: _authUser,
onSignup: _signupUser,
onSubmitAnimationCompleted: () {
Navigator.of(context).pop();
},
onRecoverPassword: _recoverPassword,
),
);
}
}
最後に
時間があったら更新します。