이 프로젝트는 개발하는남자님의 유튜브 영상을 참고하여 제작하였습니다. 허나, 원본 영상에서 제작하는 방법과는 다를 수 있습니다.
스토리 영역에는 내가 팔로우를 한 친구들의 스토리를 볼 수 있는 아바타들이 좌우로 스크롤링 될 수 있도록 구성되어있습니다. 그 중 가장 왼쪽에는 나의 스토리를 올릴 수 있는 아바타가 있으며, 스토리를 올렸다면 다른 아바타와 비슷하지만 스토리를 올리지 않았다는 상황으로 위젯을 만들어보겠습니다.
일단 이미지 아바타의 경우 기존에 바텀 네비게이션을 위해 만들어둔 위젯을 재사용하는 것이 더 좋을 것 같습니다. 원래 아바타 위젯의 코드를 보시죠.
enum Shape {ACTIVE, OFF}
class ImageAvatar extends StatelessWidget {
final String imgUrl;
final double size;
final Shape type;
const ImageAvatar(
{super.key,
required this.imgUrl,
required this.type,
required this.size});
Widget build(BuildContext context) {
switch (type) {
case Shape.ACTIVE:
return _activeAvatar();
case Shape.OFF:
return _offAvatar();
}
}
...
원래 제작한 코드는 이렇습니다. 이제, 이곳에는 다른 상황인 경우도 존재하기 때문에 enum 타입은 ACTIVE가 아니라 ON으로 바꾸어주겠습니다. 그게 더 안헷갈릴 것 같아요.
enum AvatarType { ON, OFF }
class ImageAvatar extends StatelessWidget {
final double width;
final String url;
final AvatarType type;
const ImageAvatar(
{super.key,
this.width = 30,
required this.url,
required this.type,
});
...
원래 제작한 ON타입의 아바타는 검은색 테두리가 주위에 생기죠. 근데, 스토리 아바타도 마찬가지로 그라데이션의 인스타그램의 색상으로 테두리가 있습니다. 그래서 ON아바타를 조금 다듬기만 하면 쉽게 만들 수 있습니다.
enum AvatarType { ON, OFF, STORY }
class ImageAvatar extends StatelessWidget {
final double width;
final String url;
final AvatarType type;
final void Function()? onTap;
const ImageAvatar(
{super.key,
this.width = 30,
required this.url,
required this.type,
this.onTap});
Widget build(BuildContext context) {
return switch (type) {
AvatarType.STORY => _storyAvatar(),
AvatarType.ON => _onImage(),
AvatarType.OFF => _offImage(),
};
}
enum에 STORY 타입을 추가했습니다. 아 그리고 switch case문을 좀 더 간결하게 표현할 수 있도록 문법이 추가되었더라구요. 이게 더 가독성 좋은것 같습니다. 이제 스토리 아바타의 코드를 짜겠습니다. Container에는 gradient 속성이 있어요. 해당 프로퍼티는 그라데이션 색상을 지정할 수 있도록 도와줍니다.
Widget _storyAvatar() {
return Container(
padding: const EdgeInsets.all(3.5),
decoration: const BoxDecoration(
shape: BoxShape.circle,
// 스토리 영역의 테두리를
// 그라데이션으로 줄 수 있음.
gradient: LinearGradient(
// 시작 방향 지정
begin: Alignment.bottomLeft,
// 종료 되는 방향 지정
end: Alignment.topRight,
colors: [
// hex 컬러
Color(0xfffce80a), // 노랑
Color(0xfffc3a0a), // 빨강
Color(0xffc80afc), // 보라
])),
child: Container(
padding: const EdgeInsets.all(2.0),
decoration: const BoxDecoration(
shape: BoxShape.circle, color: Colors.white),
child: _basicImage()));
}
자, 이제 스토리 영역에 사용할 아바타 위젯을 완성했습니다.
스토리 영역을 Home의 body에 지정되어있는 CustomScrollView의 Slivers에 넣어야 합니다. 근데, 옆으로 스크롤되는 위젯이란 말이죠. 이런 위젯을 CustomScrollView에 사용할 경우, SliverToBoxAdapter 위젯으로 감싸서 사용해야합니다.
Widget _story() {
return SliverToBoxAdapter(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children:
List.generate(
20,
(index) => SizedBox(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.all(4.0),
child: ImageAvatar(
width: Get.size.width * 0.2,
type: AvatarType.STORY,
url:
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS75ebrwvgVW5Ks_oLfCbG8Httf3_9g-Ynl_Q&usqp=CAU',
),
),
Padding(
padding: const EdgeInsets.all(4.0),
child: Text(
'$index번째 사용자',
textAlign: TextAlign.center,
),
)
],
),
),
),
),
),
);
}
List.generate 메소드를 이용해서 무작위의 스토리 영역을 생성할 수 있습니다. 아바타와 이름을 Column으로 세로로 배치해서 전달하면 될 것 같아요. 이렇게 스토리 영역을 완성했습니다.
내 스토리는 뭐 원래 있던 아바타에 생성하는 아이콘을 겹쳐서 생성하면 될 것 같습니다. 생성하는 아이콘은 assets으로 존재하기 때문에 만들 필요는 없어요. 이미지 아바타에서 MYSTORY 타입을 추가합니다. 아바타를 클릭하면 스토리를 올릴 수 있어야 하기 때문에 Function타입도 전달받을 수 있도록 지정하겠습니다.
enum AvatarType { ON, OFF, STORY, MYSTORY }
class ImageAvatar extends StatelessWidget {
final double width;
final String url;
final AvatarType type;
final void Function()? onTap;
const ImageAvatar(
{super.key,
this.width = 30,
required this.url,
required this.type,
this.onTap});
Widget build(BuildContext context) {
return switch (type) {
AvatarType.STORY => _storyAvatar(),
AvatarType.ON => _onImage(),
AvatarType.OFF => _offImage(),
AvatarType.MYSTORY => _myStoryAvatar(),
};
}
이제 스토리 아바타를 제작하면 됩니다. Stack 위젯은 여러가지 위젯을 겹칠 수 있도록 해줍니다.
Widget _myStoryAvatar() {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(3.5),
child: Stack(
children: [
_basicImage(),
Container(
padding: const EdgeInsets.all(3.0),
decoration: const BoxDecoration(
shape: BoxShape.circle, color: Colors.white),
child: ImageData(width: 65, path: ImagePath.addStory),
),
],
)),
);
}
요렇게 해주면 추가버튼과 이미지 아바타가 겹쳐져서 보입니다. 이제 스토리영역의 제일 왼쪽에 추가해줄겁니다. List는 cascade 연산으로 합칠 수 있는 것 기억하시나요? 그렇게 해줄게요.
Widget _story() {
return SliverToBoxAdapter(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: [
Column(
children: [
// 나의 스토리를 업로드할 수 있는 아바타 영역
Padding(
padding: const EdgeInsets.all(4.0),
child: ImageAvatar(
width: Get.size.width * 0.2,
url:
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTnnnObTCNg1QJoEd9Krwl3kSUnPYTZrxb5Ig&usqp=CAU',
type: AvatarType.MYSTORY),
),
const Padding(
padding: EdgeInsets.all(4.0),
child: Text('내 스토리'),
)
],
), // cascade 연산으로 이어줌
...List.generate(
20,
(index) => SizedBox(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.all(4.0),
child: ImageAvatar(
width: Get.size.width * 0.2,
type: AvatarType.STORY,
url:
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS75ebrwvgVW5Ks_oLfCbG8Httf3_9g-Ynl_Q&usqp=CAU',
),
),
Padding(
padding: const EdgeInsets.all(4.0),
child: Text(
'$index번째 사용자',
textAlign: TextAlign.center,
),
)
],
),
),
),
]),
),
);
}
추가 버튼의 위치가 조금 이상하죠? Stack은 겹치는 기준이 항상 0,0픽셀입니다. 0,0은 항상 상단 왼쪽이에요. 따라서, 아바타를 기준으로 제일 상단 왼쪽에 겹쳐져있는 겁니다. 해당 위치를 조정하려면 Positioned위젯을 사용하면 됩니다. Positioned위젯은 Stack내부에서 겹쳐진 위치를 조정해주는 위젯이에요.
Widget _myStoryAvatar() {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(3.5),
child: Stack(
children: [
_basicImage(),
Positioned(
// 위치 변경
bottom: 0.5, //하단부
right: 0.5, //우측
child: Container(
padding: const EdgeInsets.all(3.0),
decoration: const BoxDecoration(
shape: BoxShape.circle, color: Colors.white),
child: ImageData(width: 65, path: ImagePath.addStory),
),
),
],
)),
);
}
이제 내 스토리영역까지 구현을 완료하였습니다.