관리 메뉴

Bull

[Flutter::Widget] Card를 이용하여 트위터, 링크드인, 레딧같은 Feed 포스트 만들어보기 본문

Software Framework/Flutter

[Flutter::Widget] Card를 이용하여 트위터, 링크드인, 레딧같은 Feed 포스트 만들어보기

Bull_ 2024. 7. 17. 13:52

https://codcost.tistory.com/169

 

Feed:아무 생각이나 적기 (24-07-16)

버스를 타다가...창의력. 거창한 걸 말하는 것이 아니다. 이미 있는 것을 얼마나 활용도 높게 사용할 수 있고 적절히 사용할 수 있는지. 그리고 자신감. 표현해내는 능력.젊었을 때 한 줌이라도

codcost.tistory.com

그냥 글 쓰다가 어차피 혼자 적을 거, 나 같은 사람 있을까봐 앱으로 만들면 괜찮겠다고 생각했다.

SNS는 안하는데 트위터나 링크드인 과는 다르게 혼자 사색의 시간 보내는 글을 쓰면 재밌을 거 같았다.

근데 평소에도 그러한 아이디어 종종 나오는데 계획으로 옮기질 않아서...

이번에 만들 거는 디자인만 대충 머릿속에 있는 거 끄집어내서 해보았다.

결과물

https://www.youtube.com/shorts/Oq3oRPAVUHg

코드

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Feed View',
      home: const MyHomePage(title: 'Feed View'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey[100],
      body: Center(
        child: SingleChildScrollView(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: const [
              FeedView(
                title: '집에 들어와서..',
                date: '2024-07-18 pm 8:52',
                text: '나이가 들어서 시냅스가 많이 끊긴 걸까? 집에와서 더 적을 내용을 생각했지만 그새 까먹어 버렸다.',
              ),
              FeedView(
                title: '버스를 타다가...',
                date: '2024-07-17 pm 8:41',
                text:
                    '''창의력. 거창한 걸 말하는 것이 아니다. 이미 있는 것을 얼마나 활용도 높게 사용할 수 있고 적절히 사용할 수 있는지. 그리고 자신감. 표현해내는 능력.\n젊었을 때 한 줌이라도 더 짜내야 한다. 어릴 땐 말도 안되는 상상을 하면서 불안에 휩싸이고, 집중도 안되고, 방해만 받았다. 그러한 사실은 지금 느껴도 변하지 않을 것이다. 일단 쓰자. 쓰면 뭐라도 달라질 것이다. 생각으로 피드백하지마라. 써놓고 시간이 지난 후 피드백을 하자. 누군가 나에게 조언을 줘서 실천해보는 중이다.
                ''',
              ),
              FeedView(
                title: '완벽함에 ',
                date: '2024-07-15 am 7:49',
                text: '''완벽함에 도달하지 못할지라도,

1초짜리 퍼포먼스를 끌어올리자.''',
              ),
              FeedView(
                title: '평범한 하루',
                date: '2024-07-14 pm 9:10',
                text: '''평범한 하루를 살아가는 것이 가장 행복한 일이다.''',
              ),
              FeedView(
                title: 'Feed',
                date: '2024-06-27 am  4:14',
                text: '''항상 내 능력치에 실망하고 절망할 때 마다 


"내가 왜 그런취급을 받아야하는데" 하면서도


"내가 정말로 그것밖에 안되는 걸까.. 가끔 설렁설렁하기도 하지만 나름대로 열심히 하고 있다고 생각하는데 착각인걸까"


자존감 떨어지는 생각이 들 때 마다 자동적으로 메타인지를 하면서 번아웃없이 계속 달린다는 생각이 든다.


나 잘하고 있는 거겠지. 흔들리지 않고 무턱대고 달려도 괜찮겠지. 


지금 멈추거나 쉬더라도 나중에 봤을 때 의미없을 거라는 생각에 힘들거나 멈추고 싶지 않다.


만약 쉬더라도 어떻게 쉬는 지를 몰라서 차라리 안 쉬는 만큼도 못할 것이다.



또 다시, 내가 몸과 마음이 온전치 못한데 그것을 느끼지 못하는 건 아닐까라는 불안감도 든다.



이러한 순환구조가 반복되는 거 같다.



주저리주저리... 그냥 아무말이나 적어본다''',
              ),
              FeedView(),
              FeedView(),
              FeedView(),
              FeedView(),
            ],
          ),
        ),
      ),
    );
  }
}

class FeedView extends StatefulWidget {
  final String title;
  final String date;
  final String text;

  const FeedView(
      {super.key,
      this.title = 'Hello World',
      this.date = '2024-07-16 pm 8:10',
      this.text = '''
Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32.\n
The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.
'''});

  @override
  _FeedViewState createState() => _FeedViewState();
}

class _FeedViewState extends State<FeedView> {
  bool isExpanded = false;

  @override
  Widget build(BuildContext context) {
    bool isLongText = widget.text.length > 400;
    String shortText =
        isLongText ? widget.text.substring(0, 400) + '...' : widget.text;

    return Container(
      padding: EdgeInsets.all(3.0),
      width: double.infinity,
      child: Card(
        elevation: 0.0,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(3.0),
        ),
        color: Colors.grey[200],
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            Container(
              child: Column(
                children: [
                  Container(
                    margin: EdgeInsets.all(15.0),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Text(widget.title,
                            style: TextStyle(
                              fontSize: 20.0,
                              fontFamily: "DungGeunMo",
                            )),
                        Text(widget.date,
                            style: TextStyle(
                                fontSize: 16.0,
                                fontFamily: "DungGeunMo",
                                color: Colors.grey[500])),
                      ],
                    ),
                  ),
                ],
              ),
            ),
            Container(
              margin: EdgeInsets.fromLTRB(10.0, 0.0, 10.0, 0.0),
              child: Divider(
                thickness: 2.0,
                color: Colors.grey[500],
              ),
            ),
            Container(
              width: double.infinity,
              margin: EdgeInsets.fromLTRB(10.0, 0.0, 10.0, 0.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  AnimatedCrossFade(
                    firstChild: Text(
                      shortText,
                      style: TextStyle(
                        fontSize: 15.0,
                        fontFamily: "DungGeunMo",
                      ),
                    ),
                    secondChild: Text(
                      widget.text,
                      style: TextStyle(
                        fontSize: 15.0,
                        fontFamily: "DungGeunMo",
                      ),
                    ),
                    crossFadeState: isExpanded
                        ? CrossFadeState.showSecond
                        : CrossFadeState.showFirst,
                    duration: Duration(milliseconds: 100),
                  ),
                  if (isLongText)
                    GestureDetector(
                      onTap: () {
                        setState(() {
                          isExpanded = !isExpanded;
                        });
                      },
                      child: Text(
                        isExpanded ? 'Show less' : 'more',
                        style: TextStyle(
                          color: const Color.fromARGB(255, 126, 126, 126),
                          fontWeight: FontWeight.bold,
                          fontFamily: "DungGeunMo",
                        ),
                      ),
                    ),
                ],
              ),
            ),
            Container(
              padding: EdgeInsets.all(10.0),
              child: Row(
                children: [
                  Container(
                    margin: EdgeInsets.only(right: 10.0),
                    child: Icon(
                      Icons.favorite,
                      color: Colors.grey[500],
                    ),
                  ),
                  Container(
                    margin: EdgeInsets.only(right: 10.0),
                    child: Icon(
                      Icons.comment,
                      color: Colors.grey[500],
                    ),
                  ),
                  Container(
                    margin: EdgeInsets.only(right: 10.0),
                    child: Icon(
                      Icons.share,
                      color: Colors.grey[500],
                    ),
                  ),
                  Container(
                    margin: EdgeInsets.only(right: 10.0),
                    child: Icon(
                      Icons.save,
                      color: Colors.grey[500],
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Card Widget을 Container로 감싼 이유는 나중에 ListView로 만드는 게 좋을 것 같아서 만들었다.

여기서 중요한건 Card를 어떻게 활용했는 가를 보기위함이다.

왜 하필 Card인가?

Card Widget에 특별한 이유 같은건 없다. Container에 구현해도 되고 Column에 구현(이건 좀 힘들 듯)해도 된다.
다만 Card에는 미리 라운딩이 되어있고 그림자 처리가 들어가있다.

 

https://api.flutter.dev/flutter/material/Card-class.html

 

Card class - material library - Dart API

A Material Design card: a panel with slightly rounded corners and an elevation shadow. A card is a sheet of Material used to represent some related information, for example an album, a geographical location, a meal, contact details, etc. This is what it lo

api.flutter.dev

 

그리고 공식문서를 보면 "album, a geographical location, a meal, contact details, etc." 앨범이나 지역, 음식, 디테일 정보 등 다양하게 쓰이는 것 같다.

또한, "Flutter Why use Card widget" 라고 구글에 검색하면

list를 사용할 때도 빈번히 사용됨을 알 수 있다.
트위터나 링크드인은 어떤 언어와 프레임워크로 자세히 찾아보진 않았지만 아마 Card Widget을 쓸 수도 쓰지 않을 수도 있다.
그냥 왠지 사용하면 코드적으로 역할을 확실하게 볼 수 있으니까 깔끔하고 좋은 Widget이다.

Text에 대한 파라미터들..

이건 이 블로그에서 사용한 내 Feed들이다. 역함수관계처럼 블로그 포스팅으로 하루 일기 같은 거를 적다가 앱으로 만들면 괜찮은 모양이 나올 것 같아서 적었고 그 Feed들도 앱에서 사용되었다.

Code 설명

구조에 대한 설명은 자세히 다루지 않고 Widget의 디자인에 대해서 설명하겠다.

우선 디자인은 크게 거창한 목표가 아닌, 제목, 내용, 날짜만 들어가면 된다. 어차피 혼자 일기처럼 쓰는 앱이니까.

흔한 Reddit의 Feed 디자인이다.

 

완전히 따라하기엔 시간이 오래걸릴 거 같고 느낌만 내주고 싶었다.

나는 저기서 제목과 내용 그리고 아래 기능구현은 하지 않을 거지만 어떤 아이콘들을 넣을 것이다.

1. Container

Container(
      padding: EdgeInsets.all(3.0),
      width: double.infinity,
      child: Card(...),
      ),

카드를 덮어쓰기 위한 Container이다.

 

일종의 ListView로 반환하기 위해서 사용한 것도 있지만 패딩을 주어서 디자인을 깔끔하게 처리하기 위함도 있다.

Reddit의 이미지에는 패딩이 있는 듯 없는 듯 구현을 잘한 거 같다. 나와 다른 점은 Feed 포스팅끼리 약간의 마진없이 구분선으로 붙어있다. 그리고 암염이 살짝 들어가 있다.

2. Card

Card(
    elevation: 0.0,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(3.0),
    ),
    color: Colors.grey[200],
    child: Column(...),
),

다음은 Card이다.

 

기본으로 설정되어있는 암염은 없애주고 보더 라운딩도 3 정도로 해두었다.

이유가 뭐냐고 묻는다면 "그냥"이다. 이게 현재 내 디자인 감각으로써 그나마 괜찮아 보였기 때문이다....

3.Column

Column(
  mainAxisAlignment: MainAxisAlignment.start,
  children: [
    Container(...),
    Container(...),
    Container(...),
    Container(...),
    ],
),

이부분은 봤을 때 알기 쉬울 것이다.

 

 

1번째: 제목, 날짜

2번째: 구분선 (그림에는 박스를 못쳤다)

3번째: 내용

4번째: 그외 잡다한 아이콘들

더 이상 설명할 거 없으니 Pass!

 

 

 

3-1. Container(Column) - Container(Row)

Container(
  child: Column(
    children: [
      Container(
        margin: EdgeInsets.all(15.0),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(widget.title,
                style: TextStyle(
                  fontSize: 20.0,
                  fontFamily: "DungGeunMo",
                )),
            Text(widget.date,
                style: TextStyle(
                    fontSize: 16.0,
                    fontFamily: "DungGeunMo",
                    color: Colors.grey[500])),
          ],
        ),
      ),
    ],
  ),
),

3번에 설명했던 Container들의 1번째 Container이다.

복잡해 볼일 수 있는데 Column, Row의 부모에 Container Widget만 추가해준 것이다.

이러한 이유는 마진이나 패딩을 넣기 위해서다.

 

그렇다면 Row만 들어가도 되는데 왜 Column도 들어갔나요?

원래 안들어갔는데 GPT에게 약간 오류만 고쳐달라했다가 들어간 거 같다.

머릿속에 내 생각에서는 Column없어도 문제가 없을 거 같긴한데, Column을 넣어 놓는 것이 좋은 게, 나중에 규모가 커지면 그 아래에 닉네임이나 날짜를 아래에다가 쓸 수도 있고 프로필 사진을 넣을 수도 있기 때문에 1번째 위치에 ColumnRow를 결합해 GridView처럼 만들어도 좋을 것 같다.

 

아무튼 여기서는 간단하게 끝쪽에 배치하는 MainAxisAlignment.spaceBetween property를 추가하였다.

3-2. Container(Divider)

Container(
  margin: EdgeInsets.fromLTRB(10.0, 0.0, 10.0, 0.0),
  child: Divider(
    thickness: 2.0,
    color: Colors.grey[500],
  ),
),

구분선을 추가해준 부분이다.

3-3. Container(Column)

Container(
  width: double.infinity,
  margin: EdgeInsets.fromLTRB(10.0, 0.0, 10.0, 0.0),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      AnimatedCrossFade(
        firstChild: Text(
          shortText,
          style: TextStyle(
            fontSize: 15.0,
            fontFamily: "DungGeunMo",
          ),
        ),
        secondChild: Text(
          widget.text,
          style: TextStyle(
            fontSize: 15.0,
            fontFamily: "DungGeunMo",
          ),
        ),
        crossFadeState: isExpanded
            ? CrossFadeState.showSecond
            : CrossFadeState.showFirst,
        duration: Duration(milliseconds: 100),
      ),
      if (isLongText)
        GestureDetector(
          onTap: () {
            setState(() {
              isExpanded = !isExpanded;
            });
          },
          child: Text(
            isExpanded ? 'Show less' : 'more',
            style: TextStyle(
              color: const Color.fromARGB(255, 126, 126, 126),
              fontWeight: FontWeight.bold,
              fontFamily: "DungGeunMo",
            ),
          ),
        ),
    ],
  ),
),

해당 부분은 내용이 들어간 위젯이다.

여기서는 more을 추가하여 Feed를 늘려보는 기법을 사용했다. 딱딱 끊어지면서 늘어났다가 줄어들면 보기 안좋아서 Animation을 사용했다.

 

https://api.flutter.dev/flutter/widgets/AnimatedCrossFade-class.html

 

AnimatedCrossFade class - widgets library - Dart API

A widget that cross-fades between two given children and animates itself between their sizes. The animation is controlled through the crossFadeState parameter. firstCurve and secondCurve represent the opacity curves of the two children. The firstCurve is i

api.flutter.dev

AnimatedCrossFade는 두 위젯이 겹치면서 부드럽게 동작하는 모션을 구현할 수 있다.

crossFadeState property를 통해 어떤 위젯을 나타낼 지 구현할 수 있으니 문서를 참고하길 바란다.

bool isLongText = widget.text.length > 400;
String shortText = isLongText ? widget.text.substring(0, 400) + '...' : widget.text;

더보기(more) 부분은 400자가 넘어가면 "..." 표시와 함께 나타내도록 하였다.

그리고 more은 GestureDetector를 통해 버튼처럼 누를 수 있고 누르면 모든 본문이 나와 Cardheight가 늘어난다.

 

다시, more은 Show less로 내용을 접을 수 있게 만들었다.

3-3. Container(Row-Icons)

Container(
  padding: EdgeInsets.all(10.0),
  child: Row(
    children: [
      Container(
        margin: EdgeInsets.only(right: 10.0),
        child: Icon(
          Icons.favorite,
          color: Colors.grey[500],
        ),
      ),
      Container(
        margin: EdgeInsets.only(right: 10.0),
        child: Icon(
          Icons.comment,
          color: Colors.grey[500],
        ),
      ),
      Container(
        margin: EdgeInsets.only(right: 10.0),
        child: Icon(
          Icons.share,
          color: Colors.grey[500],
        ),
      ),
      Container(
        margin: EdgeInsets.only(right: 10.0),
        child: Icon(
          Icons.save,
          color: Colors.grey[500],
        ),
      ),
    ],
  ),
),

마지막 Container는 아이콘이나 부수적인 기능들을 넣어 놓았다.

 

기능구현은 하지 않아 디자인만 넣어놓고 다시 Icon Widget에 Container를 씌우고 마진을 추가하여 디자인에 안정감을 넣었다.