[Flutter] Instagram Clone Coding (인스타그램 클론코딩) 7 (image_picker, widgets)
오늘도 어김없는 복습타임. 현재 작성하고 인스타그램 클론코딩의 경우에는 본인이 어느정도 수정한 부분이 있을 수도 있지만, 대부분이 기존의 코드를 참고하거나 혹은 변경을 함으로써 작성한 코드들이다. 그러다 보니 미숙한 부분도 있고 아직 덜 완성된 부분도 있을 수 있다. 이 점 양해 부탁한다. 계속해서 코드와 기능은 보수를 하면서 기초적인 기능까지는 마무리를 해보려고 한다.
자 그럼 우리가 가장 먼저 인스타그램 클론코딩을 준비하면서, utils를 만들어 colors asset 그리고 global_variables asset 등을 미리 지정해주었다. 이렇게 함으로써 primary, secondary와 같은 색감이라던지 혹은 responsive reactive web 혹은 app에 기준이 될 수 있는 수치들을 미리 입력해두었다. 근데 하나가 빠진 것이 있다.
아니 인스타그램의 묘미는 쇼츠라던가 이미지를 올리고 그걸 공유하는건데 이미지는 어떻게 공유할껀데? 이건 어떻게 해야할건지 알려줘야할꺼 아니야.
넵 바로 알려드리겠슴다.
파일생성: lib/utils/utils.dart
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
pickImage(ImageSource source) async {
final ImagePicker _imagePicker = ImagePicker();
XFile? _file = await _imagePicker.pickImage(source: source);
if (_file != null) {
return await _file.readAsBytes();
}
print('No image selected');
}
showSnackBar(String content, BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(content),
),
);
}
우리는 imagePicker라는 라이브러리를 활용할 것이다. 이건 카메라와 연동해서 사진을 찍어서 앱상에 올리거나 혹은 이미지를 갤러리에서 선택할 수 있는 기능을 제공한다. 자 그럼 이것을 어떻게 구성을 하였는가 본다면,
- pickImage() 메서드는 ImageSource의 파라미터를 받아 이미지 소스를 확인한다. 그리고 이 이미지 소스는 카메라 이거나 갤러리가 될 것이다.
- ImagePicker 플러그인의 경우에는 이미지를 기기로 부터 받아온다. 만약 이미지가 정상적으로 받아와진다면 image를 bite array 형식으로 받아온다. 다만, 그게 아니라면 일정 console을 반환할것이다: No image selected.
자 이제 이미지를 구할 수 있는 기능도 구현을 하였으니, 이제 각각의 요소들을 또 만들어 보자.
화면을 구성하려면 각각의 버튼이라던가 아니면 작은 단위의 위젯들이 필요하다. 계속해서 매 페이지 마다 특정 스타일을 적용시킨다는 것은 시간 낭비 그리고 인력의 낭비일 수 있다. 그래서 우리는 자주 사용될 스몰 위젯들을 규격화 하여서 widgets라는 폴더에 입력해두려 한다.
파일생성: lib/widgets/follow_button.dart
파일생성: lib/widgets/like_animation.dart
파일생성: lib/widgets/post_card.dart
파일생성: lib/widgets/text_field_input.dart
이렇게 총 4개를 만들어 주자. 사실 각각의 코드에 대해서 설명을 하기엔 너무 디자인적 부분들이 많기 때문에 완성된 화면을 보면서 코드를 수정하며 어떤 식으로 디자인을 위한 코드가 만들어 졌는지 확인해보기 바란다.
follow_button.dart
import 'package:flutter/material.dart';
class FollowButton extends StatelessWidget {
final Function()? function;
final Color backgroundColor;
final Color borderColor;
final String text;
final Color textColor;
const FollowButton({
Key? key,
required this.backgroundColor,
required this.borderColor,
required this.text,
required this.textColor,
this.function,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(top: 2),
child: TextButton(
onPressed: function,
child: Container(
decoration: BoxDecoration(
color: backgroundColor,
border: Border.all(
color: borderColor,
),
borderRadius: BorderRadius.circular(5),
),
alignment: Alignment.center,
child: Text(
text,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
),
),
width: 250,
height: 27,
),
),
);
}
}
like_animation.dart
import 'package:flutter/material.dart';
class LikeAnimation extends StatefulWidget {
final Widget child;
final bool isAnimating;
final Duration duration;
final VoidCallback? onEnd;
final bool smallLike;
const LikeAnimation({
Key? key,
required this.child,
required this.isAnimating,
this.duration = const Duration(milliseconds: 150),
this.onEnd,
this.smallLike = false,
}) : super(key: key);
@override
State<LikeAnimation> createState() => _LikeAnimationState();
}
class _LikeAnimationState extends State<LikeAnimation>
with SingleTickerProviderStateMixin {
late AnimationController controller;
late Animation<double> scale;
@override
void initState() {
// TODO: implement initState
super.initState();
controller = AnimationController(
vsync: this,
duration: Duration(
milliseconds: widget.duration.inMilliseconds ~/ 2,
),
);
scale = Tween<double>(begin: 1, end: 1.2).animate(controller);
}
@override
void didUpdateWidget(covariant LikeAnimation oldWidget) {
// TODO: implement didUpdateWidget
super.didUpdateWidget(oldWidget);
if (widget.isAnimating != oldWidget.isAnimating) {
startAnimation();
}
}
startAnimation() async {
if (widget.isAnimating || widget.smallLike) {
await controller.forward();
await controller.reverse();
await Future.delayed(
const Duration(
milliseconds: 200,
),
);
if (widget.onEnd != null) {
widget.onEnd!();
}
}
}
@override
void dispose() {
// TODO: implement dispose
super.dispose();
controller.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: scale,
child: widget.child,
);
}
}
post_card.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:instagram_flutter/models/user.dart';
import 'package:instagram_flutter/providers/user_provider.dart';
import 'package:instagram_flutter/resources/firestore_methods.dart';
import 'package:instagram_flutter/screens/comments_screen.dart';
import 'package:instagram_flutter/utils/colors.dart';
import 'package:instagram_flutter/utils/global_variables.dart';
import 'package:instagram_flutter/utils/utils.dart';
import 'package:instagram_flutter/widgets/like_animation.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class PostCard extends StatefulWidget {
final snap;
const PostCard({
Key? key,
required this.snap,
}) : super(key: key);
@override
State<PostCard> createState() => _PostCardState();
}
class _PostCardState extends State<PostCard> {
bool isLikeAnimating = false;
int commentLen = 0;
@override
void initState() {
// TODO: implement initState
getComments();
}
void getComments() async {
try {} catch (e) {
showSnackBar(e.toString(), context);
}
setState(() {});
QuerySnapshot snap = await FirebaseFirestore.instance
.collection('posts')
.doc(widget.snap['postId'])
.collection('comments')
.get();
commentLen = snap.docs.length;
}
@override
Widget build(BuildContext context) {
final User user = Provider.of<UserProvider>(context).getUser;
final width = MediaQuery.of(context).size.width;
return Container(
// boundary for web
decoration: BoxDecoration(
border: Border.all(
color: width > webScreenSize ? secondaryColor : mobileBackgroundColor,
),
color: mobileBackgroundColor,
),
padding: const EdgeInsets.symmetric(
vertical: 10,
),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 16,
).copyWith(right: 0),
child: Row(
children: [
// HEADER SECTION
CircleAvatar(
radius: 16,
backgroundImage: NetworkImage(
widget.snap['profImage'],
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(
left: 8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.snap['username'],
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
),
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) => Dialog(
child: ListView(
padding: const EdgeInsets.symmetric(
vertical: 16,
),
shrinkWrap: true,
children: [
'Delete',
]
.map(
(e) => InkWell(
onTap: () async {
FirestoreMethods()
.deletePost(widget.snap['postId']);
Navigator.of(context).pop();
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
child: Text(e),
),
),
)
.toList(),
),
),
);
},
icon: Icon(
Icons.more_vert,
),
),
],
),
),
//IMAGE Section
GestureDetector(
onDoubleTap: () async {
await FirestoreMethods().likePost(
widget.snap['postId'],
user.uid,
widget.snap['likes'],
);
setState(() {
isLikeAnimating = true;
});
},
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.35,
width: double.infinity,
child: Image.network(
widget.snap['postUrl'],
fit: BoxFit.cover,
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isLikeAnimating ? 1 : 0,
child: LikeAnimation(
child: const Icon(
Icons.favorite,
color: Colors.white,
size: 120,
),
isAnimating: isLikeAnimating,
duration: const Duration(
milliseconds: 400,
),
onEnd: () {
setState(() {
isLikeAnimating = false;
});
},
),
)
],
),
),
// LIKE COMMENT SECTION
Row(
children: [
LikeAnimation(
isAnimating: widget.snap['likes'].contains(user.uid),
smallLike: true,
child: IconButton(
onPressed: () async {
await FirestoreMethods().likePost(
widget.snap['postId'],
user.uid,
widget.snap['likes'],
);
},
icon: widget.snap['likes'].contains(user.uid)
? Icon(
Icons.favorite,
color: Colors.red,
)
: const Icon(
Icons.favorite_border,
)),
),
IconButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => CommentsScreen(
snap: widget.snap,
),
),
),
icon: Icon(
Icons.comment_outlined,
),
),
IconButton(
onPressed: () {},
icon: Icon(
Icons.send,
),
),
Expanded(
child: Align(
alignment: Alignment.bottomRight,
child: IconButton(
icon: Icon(Icons.bookmark_border),
onPressed: () {},
),
),
)
],
),
// DESCRIPTION AND NUMBER OF COMMENTS
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DefaultTextStyle(
style: Theme.of(context)
.textTheme
.subtitle2!
.copyWith(fontWeight: FontWeight.w800),
child: Text(
'${widget.snap['likes'].length} likes',
style: Theme.of(context).textTheme.bodyText2,
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.only(
top: 8,
),
child: RichText(
text: TextSpan(
style: const TextStyle(color: primaryColor),
children: [
TextSpan(
text: widget.snap['username'],
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(
text: ' ${widget.snap['description']}',
),
],
),
),
),
InkWell(
onTap: () {},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 4,
),
child: Text(
'View all $commentLen comments',
style: const TextStyle(
fontSize: 16,
color: secondaryColor,
),
),
),
),
Container(
padding: const EdgeInsets.symmetric(
vertical: 4,
),
child: Text(
DateFormat.yMMMd().format(
widget.snap['datePublished'].toDate(),
),
style: const TextStyle(
fontSize: 16,
color: secondaryColor,
),
),
),
],
),
),
],
),
);
}
}
text_field_input.dart
import 'package:flutter/material.dart';
class TextFieldInput extends StatelessWidget {
final TextEditingController textEditingController;
final bool isPass;
final String hintText;
final TextInputType textInputType;
const TextFieldInput(
{Key? key,
required this.textEditingController,
this.isPass = false,
required this.hintText,
required this.textInputType});
@override
Widget build(BuildContext context) {
final InputBorder =
OutlineInputBorder(borderSide: Divider.createBorderSide(context));
return TextField(
controller: textEditingController,
decoration: InputDecoration(
hintText: hintText,
border: InputBorder,
focusedBorder: InputBorder,
enabledBorder: InputBorder,
filled: true,
contentPadding: const EdgeInsets.all(8),
),
keyboardType: textInputType,
obscureText: isPass,
);
}
}
오케이 이제 위젯까지 전부다 만들어졌으니 남은건, 각 화면을 구성하는 것과 main.dart를 만드는 것이다. 뭔가 챕터 5까지만 만들면 다 쓸줄 알았는데, 아닌 것 같으니 조금 더 공부한 내용을 정리해보아야겠다.
그럼 바이!