Dynamic Widget List View With GetX

Clean Code Big Poo·2023년 3월 19일
1

Flutter

목록 보기
23/38
post-thumbnail

Overview

런타임동안 RestAPI로 받아온 리스트 형대의 내용을 dynamic list으로 보여주자. 각각의 item을 보여줄 widget을 따로 만들어 dynamic widget list를 만들 것이다.

그리고, 받아온 내용을 수정하여 업데이트까지 해보자!

Example

다음 예제는 버튼으로 위젯을 추가하고 삭제하는 간단한 예제이다. 또한 TextEditingController를 사용하여 FormField 에 접근하여 값을 print하는 기능이 있다.

복사하여 테스트 해보자!

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Dynamic Widget add'),
    );
  }
}

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

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  List<DynamicWidget> _listDynamic = [];

  void _addWidget() {
    _listDynamic.add(new DynamicWidget());
    setState(() {});
  }

  void _removeWidget() {
    _listDynamic.removeLast();
    setState(() {});
  }

  _submitDate() {
    _listDynamic.forEach((widget) {
      print(widget.controller.text);
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Flexible(
              child: ListView.builder(
                itemCount: _listDynamic.length,
                itemBuilder: (_, index) => _listDynamic[index],
              ),
            ),
            Container(
              child: TextButton(
                onPressed: () {
                  _submitDate();
                },
                child: Text('Submit Data'),
              ),
            )
          ],
        ),
      ),
      bottomNavigationBar: Container(
        height: 40,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            IconButton(
              onPressed: _addWidget,
              icon: Icon(Icons.add),
            ),
            IconButton(
              onPressed: _removeWidget,
              icon: Icon(Icons.remove),
            ),
          ],
        ),
      ),
    );
  }
}

class DynamicWidget extends StatelessWidget {
  TextEditingController controller = new TextEditingController();

  
  Widget build(BuildContext context) {
    return Container(
      child: new TextField(
        controller: controller,
        decoration: new InputDecoration(hintText: 'Enter Data'),
      ),
    );
  }
}

Using GetX

Rest API로 받은 reponse를 Model list에 관리하고, 이 리스트를 넘겨주어 widget을 만들자.
외부에서 TextEditingController를 관리하여 내용을 서버에 없데이트 할 때 사용하자.

Model

시작하기에 앞서 필요한 Model을 정의해보자.

for reponse

이제 model을 정의해보자. StoryItemModel은 Rest API 로부터 받아올 reponse의 list의 item을 정의하였다.

//reponse_model.dart 
class StoryItemModel {
  StoryItemModel({
    required this.storyId,
    required this.storyDesc,
    required this.storyImages,
    required this.createdAt,
  });

  /// 스토리 id
  int storyId;

  /// 스토리 내용
  String storyDesc;

  /// 스토리 이미지
  List<PostThumbnailModel> storyImages;

  /// 스토리 생성일자
  DateTime createdAt;

  factory StoryItemModel.fromJson(Map<String, dynamic> json) => StoryItemModel(
        storyId: json["story_id"] ?? -1,
        storyDesc: json["story_desc"] ?? '',
        storyImages: json["story_images"] == null
            ? []
            : List<PostThumbnailModel>.from(json["story_images"]
                .map((x) => PostThumbnailModel.fromJson(x))),
        createdAt: DateTime.parse(json["created_at"]),
      );

  Map<String, dynamic> toJson() => {
        "story_id": storyId,
        "story_desc": storyDesc,
        "story_images": List<dynamic>.from(storyImages.map((x) => x.toJson())),
        "created_at":
            "${createdAt.year.toString().padLeft(4, '0')}-${createdAt.month.toString().padLeft(2, '0')}-${createdAt.day.toString().padLeft(2, '0')}",
      };
}

for widget edit

StoryAddItemModel는 custom widget의 바깥에서 EditingController를 삽입해주기 위해 정의한다.

//reponse_model.dart 
class StoryAddItemModel {
  TextEditingController textEditingController = TextEditingController();
  FocusNode focusNode = FocusNode();

  //스토리 item id
  int id = -1;

  //파일 경로
  String imagePath = '';

  //파일 id
  int fileId = -1;

  //삭제할 파일 리스트
  List<int> removeFileList = [];

  void dispose() {
    textEditingController.dispose();
    focusNode.dispose();
  }

  void set(
      {required String text,
      required String imgPath,
      required int storyId,
      required int fileId}) {
    textEditingController.text = text;

    imagePath = imgPath;
    id = storyId;
    this.fileId = fileId;
  }

  void setRemoveFileList(int fileId) {
    if (!fileId.isEqual(-1)) {
      removeFileList.add(fileId);
      this.fileId = -1; //초기화
    }
  }

  bool isNewItem() {
    return (id.isEqual(-1)) ? true : false;
  }
}

Load Data

Load function in GetController

//story_update_controller.dart
class StoryUpdateController extends GetxController
    implements ProviderInterface, SubmitInterface {
  static StoryUpdateController get to => Get.find();

  /// 스토리 추가 아이템 모델 리스트
  RxList<StoryAddItemModel> storyItems = <StoryAddItemModel>[].obs;

  ///이 함수를 호출하여 rest API로부터 데이터를 받아옴
  
  Future<void> handleInitialization() async {
    try {
      AuthBaseResponseModel response = await Provider.dio(
          method: 'GET', url: 'url here');
      switch (response.statusCode) {
        case 200:
          List<StoryItemModel> items = StoryItemModel.fromJson(response.data);

          //Data -> Widget
          for (StoryItemModel storyItem in items) {//List<StoryItemModel>
            StoryAddItemModel addItemModel = StoryAddItemModel();
            addItemModel.set(
                storyId: storyItem.storyId,
                fileId: storyItem.storyImages.isEmpty
                    ? -1
                    : storyItem.storyImages[0].fileId,
                text: storyItem.storyDesc,
                imgPath: storyItem.storyImages.isEmpty
                    ? ''
                    : storyItem.storyImages[0].fileUrl); //file은 하나
            storyItems.add(addItemModel);
          }
          break;
        case 500:
          throw Exception("서버 오류입니다");
        default:
          throw Exception(response.message);
      }
      setIsLoding(false);
    } catch (e) {
      GlobalToastWidget(message: e.toString().substring(11));
    }
  }

Load to View

받아온 데이터를 이제 화면에 보여주자.

Obx

RxList가 변경되면 갱신할 화면의 부분을 Obx로 감싼다.

주의!
Obx(() => null) 로 감싸 줄때 내부에 GetController에서 관찰 가능한 변수가 존재 하지 않은 경우 에러가 발생한다. 그러니 꼭 확인후 사용하도록 하자.

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        FocusManager.instance.primaryFocus?.unfocus();
      },
      //Field 밖을 터치하여 키보드를 해제한다. 
      //TextEditingController를 사용하는 경우에는 필히 쓰는 것이 UX상 좋겠다.
      child: _scaffoldView(),
    );
  }

  Widget _scaffoldView() {
    return Scaffold(
        appBar: const GlobalBackAppBar(
          "스토리 수정하기",
          centerTitle: true,
        ),
        body: Obx(
          () => controller.isLoding
              ? _loadingView()
              : !controller.isDataImport.value
                  ? const ErrorPage()
                  : _customView(),
        ),
        bottomNavigationBar: _bottomButton());
  }

ListView.builder

ListView.builder에서는 GetController의 list의 갯수만큼 builde 해준다. refresh 할때마다 setState한것과 마찬가지로 위젯을 재빌드한다.

//StoryPage.dart
Widget _listItem() {
    return ListView.builder(
      itemCount: controller.storyItems.length,
      itemBuilder: (_, index) => Column(
        children: [          
          StoryItemWidget(
            item: controller.storyItems[index],
            onPressed: () async {
              controller.picImage(index);
              //index를 사용하여 GetController내에서 이벤트를 처리한다.
            },
          ),
        ],
      ),
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
    );
  }

Obx 로 감싸 Rx로 값 변경 Notify를 감지한다. 버튼을 생성하여 onEvent에 addWidget과 removeWidget을 호출한다.

ListView.Builder 만 스크롤 된다? 이렇게 해결하자

  • SingleChildScrollView로 감싸기
  • shrinkWrap: true,
  • physics: const NeverScrollableScrollPhysics(),

Custom Widget

widget에 필요한 함수들은 GetController에 정의하고 VoidCallback을 사용하여 이벤트를 전달해준다. 위젯을 위해 정의한 StoryAddItemModel 또한 전달하여 사용한다.

//story_item_widget.dart

class StoryItemWidget extends StatelessWidget {
  const StoryItemWidget(
      {required this.onPressed, required this.item, super.key});

  final StoryAddItemModel item;
  final VoidCallback onPressed;

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        SizedBox(height: 5.h),
        GestureDetector(
          child: Text(''),
          onTap: () {
            onPressed();
          },
        ),
        SizedBox(height: 10.h),
        GlobalInputField(
          controller: item.textEditingController,
          error: '',
          height: 100.h,
          expands: true,
          maxLines: null,
          focusNode: item.focusNode,
          hitText: '오늘 있었던 일을 사진과 함께 기록해보세요.',
          label: '내용 입력',
          onChange: () {},
        ),
      ],
    );
  }
}

event for widget in GetController

ListView.Builder의 index로 위젯을 찾아 이미지를 바꾸어 준다.
storyItems.refresh()를 통해 update하면 RxList이기 때문에 화면을 갱신한다.

//story_update_controller.dart
  /// item 이미지 선택
  Future picImage(int index) async {
    try {
      var img = await ImagePicker().pickImage(source: ImageSource.gallery);

      if (img == null) {
        return;
      }

      storyItems[index].imagePath = img.path;
      storyItems[index].setRemoveFileList(storyItems[index].fileId); //file id

      storyItems.refresh();
    } on PlatformException catch (e) {
      Logger().d('[ERROR] pickImage error : $e');
    }
  }

add/remove Custom Widget

사용자가 직접 위젯을 추가하고 삭제할 수 있도록 해보자.

GetController

addWidget()와 removeWidget()를 정의한다.
addWidget()는 새로운 custom widget을 추가한다. storyItems는 RxList이므로, 내용이 변경되면 UI를 업데이트한다.
removeWidget()는 삭제된 list를 담는다. 이유는 서버에서도 같은 storyitem이 삭제되어야 하기 때문에 index를 넘겨주는 역할을 하게 된다.

class StoryUpdateController extends GetxController
    implements ProviderInterface, SubmitInterface {
  static StoryUpdateController get to => Get.find();
  /// 스토리 추가 아이템 모델 리스트
  RxList<StoryAddItemModel> storyItems = <StoryAddItemModel>[].obs;

  /// 삭제할 스토리 id 리스트
  List<String> removeStoryIdList = [];

  /// 스토리 위젯 추가
  void addWidget() {
    storyItems.add(StoryAddItemModel());
  }

  /// 스토리 위젯 삭제
  void removeWidget() {
    if (storyItems.isEmpty) return;

    int removeIdx = storyItems.length - 1;

    removeStoryIdList.add('${storyItems[removeIdx].id}'); //story id
    storyItems[removeIdx].dispose();
    storyItems.removeAt(removeIdx);
  }
}  

View

//story_item_widget.dart
  Widget _addItem() {
    return SizedBox(
      height: 50.w,
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          Text(
            '스토리를 삭제 또는 추가하려면 버튼을 눌러 주세요.',
            style: TextStylePath.small12w400
                .copyWith(color: const Color(0xff616161)),
          ),
          const Spacer(),
          AddPostIconButton(//-버튼
            icons: Icons.remove,
            onPressed: controller.removeWidget,
          ),
          SizedBox(width: 6.h),
          AddPostIconButton(//+버튼
            icons: Icons.add,
            onPressed: controller.addWidget,
          ),
        ],
      ),
    );
  }

Submit Data

위젯의 TextEiditingController을 사용하여 서버로 data를 보내자.

Submit function in GetController

Custom Widget의 TextEditingController를 담고 있는 StoryAddItemModel를 사용하여 서버에 데이터를 전송한다.
submitStoryItem하기 전에 TextEditingController의 validate를 확인하는 하는 과정도 필요하다.

/// Item 업데이트
  Future<bool> submitStoryItem(postId) async {
    try {
      for (StoryAddItemModel item in storyItems) {        
          ///story item 추가
          Dio.FormData formdata = Dio.FormData.fromMap({
            "story_desc": item.textEditingController.value.text, //required
            "story_images": [await Dio.MultipartFile.fromFile(item.imagePath)],
            'remove_images': '',
          });

          AuthBaseResponseModel response = await Provider.dio(
              method: 'POST',
              url: 'add url here',
              requestModel: formdata);

          switch (response.statusCode) {
            case 200:
            case 201:
              result = true;

              break;
            default:
              throw Exception(response.message);
          }
        } 
      }
    } catch (e) {
      Logger().d('[ERROR] $e');
      result = false;
    }

    return result;
  }

참고

Dynamic Widget

0개의 댓글