[Flutter] 공공 API로 주소 검색 & 주소 선택기 만들기 (BLoC)

Tyger·2023년 6월 1일
1

Flutter

목록 보기
40/56

공공 API로 주소 검색 & 주소 선택기 만들기 (BLoC)

SGIS Developers
Postman API Platform

이번 글에서는 공공 API를 활용한 주소 검색 기능을 만들어 보려고 한다.

데이터가 없어서 이것저것 찾다 보니 통계청에서 운영하는 국내 시/군/구 API가 있어서 해당 데이터를 사용하여 주소 검색 기능을 두 가지 방식으로 개발하였다.

스크롤 포커싱을 통한 주소 검색 방법이 있고, 다른 하나는 단계별 주소 검색 방법이다.

API 신청 & Test

아래 통계청에서 운영하는 지리정보서비스에 접속하여 API 사용에 필요한 서비스 키를 신청을 진행해보자.

https://sgis.kostat.go.kr/developer/html/newOpenApi/api/dataApi/addressBoundary.html

해당 사이트로 접속하면 아래의 화면이 나오는데, 우선 로그인을 진행해야 한다. 해당 계정은 통합 ID로 운영되고 있어서 공공 API를 사용해보신 분들은 아마도 계정이 있을 것이다.

로그인을 했으면, 상단 탭에서 인증키 발급센터 > 나의 인증키 신청으로 이동을 하자.

인증키 신청을 하는 페이지인데, 나의 인증키에서 발급된 인증키가 있다면, 아래 절차는 진행하지 않아도 된다.
인증키 발급이 필요하면 약관에 동의를 클릭하자.

인증키 신청 팝업에서 서비스명은 자유롭게 작성하면 되고, 사용기간 또한 원하는 기간을 선택해주자.

인증키가 정상적으로 발급이 되고, 서비스 ID / 보안 Key도 발급이 되어있는 것을 확인할 수 있다.

이제 API를 어떻게 사용해야 하는지에 대해서 살펴보자. 데이터 API 탭을 클릭해서 이동하자.

준비하기 내용을 보면 우리가 좀 전에 진행한 인증키 발급 신청이 있는데, 이미 발급을 진행하였기에 다음 단계로 넘어가도록 하자.

API 정의 및 사용예제 탭을 살펴보면 AccessToken을 발급 받는 방법에 대해서 나와있는 문서를 확인할 수 있다.

AccessToken은 API를 호출하기 위한 인증 수단으로, 인증키를 발급 받았을 때에 함께 발급된 서비스 ID와 보안 Key를 통해서 발급 받아 사용할 수 있다.

참고로 AccessToken의 만료 시한은 4시간으로 매우 짧기 때문에, AccessToken에 대한 기간을 관리할 수 있는 로직도 필요해 보인다.

주소경계 탭의 단계별 주소 조회로 이동해서 문서를 확인하도록 하자. 해당 API를 사용해서 주소 검색 & 선택기를 개발할 것이다.

해당 API를 호출하기 위해 accessToken이 필요하며, cd를 통해 code를 추가해주면, 해당 코드에 맞는 지역을 다시 반환해 주는 구조이다. 아래에서 테스트 하면서 구조를 자세히 살펴보도록 하자.

Postman

Postman에서 발급 받은 서비스 ID와 보안 Key를 통해 accessToken을 발급 받아 단계별 주소 조회 API를 정상적으로 호출하는지 테스트를 먼저 해보겠다.

https://www.postman.com/

Postman 사이트로 이동해서 My Workspaces를 클릭하면 된다.

아래의 페이지에서 노란색 박스의 추가하기 버튼을 클릭해 준다.

호출 방식을 GET으로 설정한 뒤에 문서에 나와 있는데로 주소 값을 넣어주자. 그리고 Params 부분에서 Key-Value에 각각의 consumer_key와 consumer_secret를 추가해서 Send를 클릭해보자.

GET : https://sgisapi.kostat.go.kr/OpenAPI3/auth/authentication.json
consumer_key : {서비스ID}
consumer_secret : {보안Key}

호출이 성공하고 accessToken이 정상적으로 호출된 것을 확인할 수 있다.
accessTimeout 파라미터의 값이 인증 만료 기한인데, TimeStamp로 되어있으니, DateTime으로 변환해서 사용하시면 된다.

이번에는 실제 주소 정보를 호출해 보도록 하자. cd는 옵셔널 파라미터로 cd 값이 없으면, 시 정보를 반환 받을 수 있다.

GET : https://sgisapi.kostat.go.kr/OpenAPI3/addr/stage.json
accessToken : {your_accessToken}

정상적으로 호출에 성공하였다. 반환 타입을 살펴보면 좌표 정보와 주소명, 코드 값의 데이터가 있는 것을 확인 할 수있다.

이번에는 위에서 발급 받은 cd 값을 사용해서 서울특별시의 세부 구에 대한 정보를 가져오도록 하자. cd : 11을 넣어주면 된다.

서울특별시의 구에 대한 주소 정보를 정상적으로 리턴하고 있다.

이번에는 cd 값에 위에서 반환 받은 5자리수 cd를 넣어서 호출하자. 이제 각 세부 동까지 정보를 받아올 수 있는 것을 확인했다.

여기서 해당 API는 더 이상의 cd 정보를 호출할 수 없다. 문서를 확인해보면 cd의 자릿수는 최대 5자리 수 까지만 지원한다고 한다. 하지만 우리가 개발하려고 하는 기능에는 문제가 없다.

Flutter

공공 API를 신청하고 토큰을 발급 받아 실제 사용할 API를 호출하는데 까지 살펴봤다. 이제 Flutter에서 데이터를 호출하여 개발을 진행해 보도록 하자.

BLoC Pattern을 사용해서 개발을 진행할 것이며, API 호출을 위해서 http 플러그인을 사용하도록 하겠다.

dependencies

dependencies:
	flutter_bloc: ^8.1.1
	http: ^0.13.5

이번에 만들어볼 예제는 두 가지를 만들어 볼 것인데, 먼저 만들어 볼 예제는 시 > 구 > 동의 Depth 구조를 가지고 있는 주소 선택기와 시구동 정보가 하나의 페이지에서 스크롤로 포지션을 이동 시키면서 선택할 수 있는 구조의 주소 선택기이다.

각각 Depth 구조의 기능을 "단계별 주소 선택기"라 하고 스크롤 포지션을 이동 시키면서 주소 정보가 노출 되어있는 기능을 "스크롤 포커싱 주소 검색"이라고 하겠다.

단계별 주소 선택기

Repository

Repository Layer 부터 작성을 해보자. Postman에서 테스트한 방법과 동일하게 accessToken을 발급 받아 API 호출 토큰으로 사용하는 로직을 만들기 위해 API로 부터 데이터를 받아올 수 있는 계층 먼저 개발을 하도록 하자.

getSgisApiAccessToken()에서 서비스ID & 보안Key를 사용해서 accessToken을 받아오는 부분을 먼저 작성해 보자.

API 반환 구조가 json 형태이기에, 해당 json을 디코딩하여 Map 형태로 변환해주고 있다. json 구조를 보면 result 변수안에 accessToken 정보가 반환되고 있다.

class AppAddressRepository {
  static final AppAddressRepository instance = AppAddressRepository._internal();
  factory AppAddressRepository() => instance;
  AppAddressRepository._internal();
  
  Future<String?> getSgisApiAccessToken() async {
    try {
      const String _key = {서비스ID};
      const String _secret = {보안Key};
      http.Response _response = await http.get(
        Uri.parse(
            "https://sgisapi.kostat.go.kr/OpenAPI3/auth/authentication.json?consumer_key=$_key&consumer_secret=$_secret"),
      );
      if (_response.statusCode == 200) {
        Map<String, dynamic> _body =
            json.decode(_response.body) as Map<String, dynamic>;
        String _accessToken = _body["result"]["accessToken"];
        return _accessToken;
      } else {
        return null;
      }
    } catch (error) {
      return null;
    }
  }
}

이번에는 주소 정보를 받아오는 부분인데, 필수 값으로 token 값을 받아와야 하고, cd 파라미터에 들어갈 code 값은 Nullable 타입으로 null일 경우에는 cd 값을 넣지 않도록 하였다.

AddressDepthServerModel이라는 객체를 받아와야 하기에 해당 모델도 만들어 보자.

Future<List<AddressDepthServerModel>> depthAddressInformation({
    required String token,
    String? code,
  }) async {
    try {
      String? _code = code == null ? "" : "&cd=$code";
      http.Response _response = await http.get(Uri.parse(
          "https://sgisapi.kostat.go.kr/OpenAPI3/addr/stage.json?accessToken=$token$_code"));
      if (_response.statusCode == 200) {
        Map<String, dynamic> _body =
            json.decode(_response.body) as Map<String, dynamic>;
        List<dynamic> _result = _body["result"];
        List<AddressDepthServerModel> _model =
            _result.map((e) => AddressDepthServerModel.fromJson(e)).toList();
        return _model;
      } else {
        return [];
      }
    } catch (error) {
      return [];
    }
  }

Model

데이터에 필요한 정보는 code와 name만 있으면 되서 해당 값만 추가해 주었다. 추가적으로 더 필요한 변수가 있다면 추가하시면 된다.

class AddressDepthServerModel {
  final String code;
  final String name;

  AddressDepthServerModel({
    required this.code,
    required this.name,
  });

  factory AddressDepthServerModel.fromJson(Map<String, dynamic> json) {
    return AddressDepthServerModel(
      code: json["cd"],
      name: json["addr_name"],
    );
  }
}

Application

BLoC 패턴을 사용한 비즈니스 로직을 개발해 보도록 하자.

개발에 앞서 어떠한 구조로 로직을 생성할 지에 대해서 먼저 고민을 해야한다. "시"를 선택하면 "구" 정보를 받아서 "시"를 좌측으로 보내고 우측에 "구" 정보를 보여줄 것이다. 이어서 "구"를 서택하면 "시" 정보는 뷰에서 사라지고 "구" 정보가 좌측으로 보내지게 된다. 이 때 우측에는 "동"의 정보가 나타나게 될 것이다.

State

"시"를 선택한 경우, "구"를 선택한 경우, "동"이 선택되어진 경우 이렇게 3가지의 상태가 필요하며, 초기 페이지 진입시 "시"정보 API를 받아와야 하기에 초기 상태도 필요하다.
마지막으로 가장 중요한 에러가 발생 했을시에 대한 에러 상태도 만들어야 한다.

여기서 시/군/구를 Major/Middle/Minor라고 상태를 생성해주려고 한다.

Bloc의 State를 생성해주고, 필요한 모델인 AddressDepthModel을 등록해주자.

abstract class AddressDepthState extends Equatable {
  final String? accessToken;
  final AddressDepthModel? address;
  const AddressDepthState({
    this.accessToken,
    this.address,
  });
}

위에서 정의한 상태를 생성해주도록 하자.

class AddressDepthInitState extends AddressDepthState {
  const AddressDepthInitState({super.address});
  
  List<Object?> get props => [address];
}

class AddressDepthMajorState extends AddressDepthState {
  const AddressDepthMajorState({super.accessToken, super.address});
  
  List<Object?> get props => [address, accessToken];
}

class AddressDepthMiddleState extends AddressDepthState {
  const AddressDepthMiddleState({super.accessToken, super.address});

  
  List<Object?> get props => [address];
}

class AddressDepthMinorState extends AddressDepthState {
  const AddressDepthMinorState({super.accessToken, super.address});

  
  List<Object?> get props => [address];
}

class AddressDepthErrorState extends AddressDepthState {
  const AddressDepthErrorState({super.address});
  
  List<Object?> get props => [];
}
Event

이번엔 Event에 대해서 살펴보자. 상태와 동일하게 Major/Middle/Minor 상태로 변경하기 위해서 각각의 Event도 필요하며, 초기화시에 사용할 이벤트와 마지막 Minor 상태로 변경하기 위한 종료 이벤트가 필요하다.

각 이벤트의 Arguments는 Bloc 로직을 만들면서 살펴보도록 하겠다.

abstract class AddressDepthEvent extends Equatable {}

class AddressDepthMajorEvent extends AddressDepthEvent {
  
  List<Object?> get props => [];
}

class AddressDepthMiddleEvent extends AddressDepthEvent {
  final AddressDepthServerModel selected;

  AddressDepthMiddleEvent({required this.selected});
  
  List<Object?> get props => [selected];
}

class AddressDepthMinorEvent extends AddressDepthEvent {
  final AddressDepthServerModel selected;

  AddressDepthMinorEvent({required this.selected});
  
  List<Object?> get props => [selected];
}

class AddressDepthFinishEvent extends AddressDepthEvent {
  final AddressDepthServerModel selected;

  AddressDepthFinishEvent({required this.selected});

  
  List<Object?> get props => [selected];
}

class AddressDepthResetEvent extends AddressDepthEvent {
  final int type;

  AddressDepthResetEvent({required this.type});
  
  List<Object?> get props => [];
}
Bloc

Bloc을 생성해서 각 이벤트의 로직을 만들어보자. 여기서 이벤트로 API를 호출하여야 되기 때문에, Repository Layer에서 생성한 객체를 인스턴스하도록 하자.

class AddressDepthBloc extends Bloc<AddressDepthEvent, AddressDepthState> {
  final AppAddressRepository _repository = AppAddressRepository.instance;
  AddressDepthBloc()
      : super(AddressDepthInitState(address: AddressDepthModel.empty())) {
    on<AddressDepthMajorEvent>(_major);
    on<AddressDepthMiddleEvent>(_middle);
    on<AddressDepthMinorEvent>(_minor);
    on<AddressDepthFinishEvent>(_finish);
    on<AddressDepthResetEvent>(_reset);
    add(AddressDepthMajorEvent());
  }
}

해당 이벤트는 Bloc이 생성되면, 바로 호출하게될 이벤트이다. 로직을 확인해보면, accessToken의 상태가 null일 경우 먼저 토큰을 발급받아 주소 정보를 홏출하고 있는데, 주소 정보에 code 값을 주지 않았으니, "시" 정보를 호출하게 된다.

해당 데이터로 AddressDepthMajorState()의 상태로 변경해주어 UI에 변경을 알려주고 있다.

Future<void> _major(
      AddressDepthMajorEvent event, Emitter<AddressDepthState> emit) async {
    if (state.address!.major.address.isEmpty) {
      String? _token = state.accessToken;
      if (_token == null) {
        String? _result = await _repository.getSgisApiAccessToken();
        if (_result == null) {
          emit(AddressDepthErrorState(address: state.address));
        } else {
          _token = _result;
        }
      }
      if (_token != null) {
        List<AddressDepthServerModel> _result =
            await _repository.depthAddressInformation(token: _token);
        if (_result.isNotEmpty) {
          emit(AddressDepthMajorState(
              address: state.address!.coptyWith(
                major: AddressDepthDetailModel(current: null, address: _result),
              ),
              accessToken: _token));
        } else {
          emit(AddressDepthErrorState(address: state.address));
        }
      }
    }
  }

Middle/Minor의 이벤트는 동일하며, 필수 값으로 AddressDepthServerModel을 받아와 code를 호출하여, 선택된 코드 값으로 다음 데이터를 호출하고 있다.

 Future<void> _middle(
      AddressDepthMiddleEvent event, Emitter<AddressDepthState> emit) async {
    HapticFeedback.mediumImpact();
    List<AddressDepthServerModel> _result =
        await _repository.depthAddressInformation(
            token: state.accessToken!, code: event.selected.code);
    if (_result.isNotEmpty) {
      emit(AddressDepthMiddleState(
          address: state.address!.coptyWith(
            major: state.address!.major.copyWith(current: event.selected),
            middle: AddressDepthDetailModel(current: null, address: _result),
          ),
          accessToken: state.accessToken));
    } else {
      emit(AddressDepthErrorState(address: state.address));
    }
  }
Future<void> _minor(
      AddressDepthMinorEvent event, Emitter<AddressDepthState> emit) async {
    HapticFeedback.mediumImpact();
    List<AddressDepthServerModel> _result =
        await _repository.depthAddressInformation(
            token: state.accessToken!, code: event.selected.code);
    if (_result.isNotEmpty) {
      emit(AddressDepthMinorState(
          address: state.address!.coptyWith(
            middle: state.address!.middle.copyWith(current: event.selected),
            minor: AddressDepthDetailModel(current: null, address: _result),
          ),
          accessToken: state.accessToken));
    } else {
      emit(AddressDepthErrorState(address: state.address));
    }
  }

AddressDepthFinishEvent 이벤트는 "동"의 정보를 클릭했을 때, 데이터를 저장하기 위해 사용되고 있다.

Future<void> _finish(
      AddressDepthFinishEvent event, Emitter<AddressDepthState> emit) async {
    HapticFeedback.mediumImpact();
    emit(AddressDepthMinorState(
        address: state.address!.coptyWith(
            minor: state.address!.minor.copyWith(current: event.selected)),
        accessToken: state.accessToken));
  }

시/구/동의 데이터를 다시 호출할 때에 사용하는 로직이다.

Future<void> _reset(
      AddressDepthResetEvent event, Emitter<AddressDepthState> emit) async {
    HapticFeedback.mediumImpact();
    switch (event.type) {
      case 0:
        emit(AddressDepthMajorState(
            address: state.address!.coptyWith(
              major: state.address!.major.copyWith(current: null),
              middle: state.address!.middle.copyWith(current: null),
              minor: state.address!.minor.copyWith(current: null),
            ),
            accessToken: state.accessToken));
        break;
      case 1:
        emit(AddressDepthMiddleState(
          address: state.address!
              .coptyWith(minor: state.address!.minor.copyWith(current: null)),
          accessToken: state.accessToken,
        ));
        break;
      default:
    }
  }
Presentation

UI 영역의 코드이다. 해당 영역은 코드만 작성하도록 하겠다.

class AddressDepthScreen extends StatelessWidget {
  const AddressDepthScreen({super.key});

  
  Widget build(BuildContext context) {
    double _width = MediaQueryData.fromWindow(window).size.width;
    return BlocProvider<AddressDepthBloc>(
      create: (_) => AddressDepthBloc(),
      child: BlocConsumer<AddressDepthBloc, AddressDepthState>(
        listener: (context, state) {
          if (state is AddressDepthErrorState) {
            Navigator.of(context).pop();
          }
        },
        builder: (context, state) {
          if (state is AddressDepthInitState) {
            return const Scaffold(
              body: Center(
                child: CircularProgressIndicator(
                  color: Colors.amber,
                ),
              ),
            );
          }
          return Scaffold(
            appBar: appBar(title: "Depth Address"),
            body: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                SizedBox(
                  width: _width,
                  height: 60,
                  child: DefaultTextStyle(
                    style: const TextStyle(
                        fontWeight: FontWeight.bold,
                        fontSize: 18,
                        color: Colors.amber),
                    child: Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 20),
                      child: Row(
                        children: [
                          if (state is AddressDepthMajorState) ...[
                            const Text("주소를 선택해주세요")
                          ],
                          if (state.address!.major.current != null) ...[
                            GestureDetector(
                                onTap: () => context
                                    .read<AddressDepthBloc>()
                                    .add(AddressDepthResetEvent(type: 0)),
                                child:
                                    Text(state.address!.major.current!.name)),
                          ],
                          if (state.address!.middle.current != null) ...[
                            const Padding(
                              padding:
                                  EdgeInsets.only(left: 4, right: 4, bottom: 2),
                              child: Icon(
                                Icons.arrow_forward_ios_rounded,
                                size: 14,
                                color: Colors.amber,
                              ),
                            ),
                            GestureDetector(
                                onTap: () => context
                                    .read<AddressDepthBloc>()
                                    .add(AddressDepthResetEvent(type: 1)),
                                child:
                                    Text(state.address!.middle.current!.name)),
                          ],
                          if (state.address!.minor.current != null) ...[
                            const Padding(
                              padding:
                                  EdgeInsets.only(left: 4, right: 4, bottom: 2),
                              child: Icon(
                                Icons.arrow_forward_ios_rounded,
                                size: 14,
                                color: Colors.amber,
                              ),
                            ),
                            Text(state.address!.minor.current!.name),
                          ],
                        ],
                      ),
                    ),
                  ),
                ),
                Expanded(
                  child: Row(
                    children: [
                      if (state is! AddressDepthMinorState) ...[
                        _listView(
                          width: state is AddressDepthMajorState ? _width : 130,
                          color: const Color.fromRGBO(61, 61, 61, 1),
                          selecteColor: const Color.fromRGBO(71, 71, 71, 1),
                          onTap: (i) => context.read<AddressDepthBloc>().add(
                              AddressDepthMiddleEvent(
                                  selected: state.address!.major.address[i])),
                          address: state.address!.major.address,
                          selected: state.address!.major.current,
                        )
                      ],
                      if (state is AddressDepthMiddleState ||
                          state is AddressDepthMinorState) ...[
                        _listView(
                          width: state is AddressDepthMiddleState
                              ? _width - 130
                              : 130,
                          color: const Color.fromRGBO(71, 71, 71, 1),
                          selecteColor: const Color.fromRGBO(81, 81, 81, 1),
                          onTap: (i) => context.read<AddressDepthBloc>().add(
                              AddressDepthMinorEvent(
                                  selected: state.address!.middle.address[i])),
                          address: state.address!.middle.address,
                          selected: state.address!.middle.current,
                        )
                      ],
                      if (state is AddressDepthMinorState) ...[
                        _listView(
                          width: _width - 130,
                          color: const Color.fromRGBO(81, 81, 81, 1),
                          selecteColor: Colors.green,
                          onTap: (i) => context.read<AddressDepthBloc>().add(
                              AddressDepthFinishEvent(
                                  selected: state.address!.minor.address[i])),
                          address: state.address!.minor.address,
                          selected: state.address!.minor.current,
                        )
                      ],
                    ],
                  ),
                )
              ],
            ),
          );
        },
      ),
    );
  }

  _listView({
    required double width,
    required Function(int) onTap,
    required List<AddressDepthServerModel> address,
    required Color color,
    required Color selecteColor,
    required AddressDepthServerModel? selected,
  }) {
    return Container(
      color: color,
      width: width,
      child: ListView.builder(
        itemCount: address.length,
        itemBuilder: (context, index) {
          return GestureDetector(
            onTap: () => onTap(index),
            child: Container(
              color: selected == null
                  ? Colors.transparent
                  : selected == address[index]
                      ? selecteColor
                      : color,
              height: 50,
              child: Padding(
                padding: const EdgeInsets.only(left: 20),
                child: Row(
                  children: [
                    Text(
                      address[index].name,
                      style: const TextStyle(
                          fontWeight: FontWeight.bold,
                          color: Color.fromRGBO(215, 215, 215, 1),
                          fontSize: 16),
                    ),
                  ],
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}
`

Result

Git

https://github.com/boglbbogl/flutter_velog_sample/tree/main/lib/app/address/depth

스크롤 포커싱 주소 검색

이번에는 주소 정보를 한 페이지에 리스트로 보여주고 스크롤로 이동시에 해당하는 시 정보와 구-동 정보를 포커싱하면서 주소를 검색할 수 있는 기능에 대해서 만들어보도록 하겠다.

여기서는 API를 호출하지 않고 로컬에 json 파일을 만들어서 사용해 보도록 하겠다.

assets/json

로컬에서 json 파일을 사용하려면 데이터가 저장되어 있어야 하기에 데이터를 먼저 저장해 주도록 하자.

Project 수준에서 assets 폴더를 하나 생성하자. 만일 assets 폴더가 있다면 해당 폴더를 사용하면 된다. assets 폴더안에 json 폴더를 생성해서 위의 이미지와 같은 구조로 데이터를 세팅하면 된다.

pubspec.yaml에 assets을 등록해주면 된다.

assets:
    - assets/json/
    - assets/json/minor/

아래 경로로 이동해서 json 파일을 다운 받아 사용하시면 된다.

https://github.com/boglbbogl/flutter_velog_sample/tree/main/assets/json

Repository

위에서 생성한 Repository Layer에 아래 코드를 추가해주자. 해당 코드는 우리가 저장한 assets의 json 파일을 가지고 디코딩 과정을 통해 AddressStepModel로 변환해서 데이터를 바인딩 해주는 부분이다.

 Future<List<AddressStepModel>> assetsJsonToAddress() async {
    String _major =
        await rootBundle.loadString("assets/json/address_major.json");
    List<dynamic> _data = json.decode(_major)["result"];
    List<AddressStepMinor> _majors =
        _data.map((e) => AddressStepMinor.fromJson(e)).toList();
    List<AddressStepModel> _address = [];
    for (int i = 0; i < _majors.length; i++) {
      String _minor = await rootBundle
          .loadString("assets/json/minor/address_minor_${i + 1}.json");
      List<dynamic> _minorData = json.decode(_minor)["result"];
      List<AddressStepMinor> _minors =
          _minorData.map((e) => AddressStepMinor.fromJson(e)).toList();
      _address.add(AddressStepModel(name: _majors[i].name, names: _minors));
    }
    return _address;
  }

Model

여기서는 names라는 AddressStepMinor 객체를 가지고 있는 배열을 사용해서 모델로 사용하고 있다.

class AddressStepModel {
  final String name;
  final List<AddressStepMinor> names;

  const AddressStepModel({required this.name, required this.names});
}

class AddressStepMinor {
  final String name;

  AddressStepMinor({required this.name});

  factory AddressStepMinor.fromJson(Map<String, dynamic> json) {
    return AddressStepMinor(name: json["addr_name"]);
  }
}

Application

로직 부분을 작성할 것인데, 여기서는 Cubit을 사용해서 상태 관리를 진행 하였으며, 스크롤 픽셀에 의한 포커싱을 다루다 보니 높이 값을 계산하는 것이 복잡한거고 나머지 로직은 어렵지는 않을 것이다.

State

여기서는 copyWith 방식을 사용한 State 관리를 하였다. address라는 변수를 생성하고, 중요한 변수 중 하나가 스크롤 포커싱을 맞추기 위해 GlobalKey를 사용하여야 한다.

class AddressStepState extends Equatable {
  final List<AddressStepModel>? address;
  final int positionIndex;
  final List<GlobalKey>? globalKeys;
  final bool isLoading;
  final double currentHeight;

  const AddressStepState({
    this.address,
    this.positionIndex = 0,
    this.globalKeys,
    this.isLoading = false,
    this.currentHeight = 0.0,
  });

  AddressStepState copyWith({
    final List<AddressStepModel>? address,
    final int? positionIndex,
    final List<GlobalKey>? globalKeys,
    final bool? isLoading,
    final double? currentHeight,
  }) {
    return AddressStepState(
      address: address ?? this.address,
      positionIndex: positionIndex ?? this.positionIndex,
      globalKeys: globalKeys ?? this.globalKeys,
      isLoading: isLoading ?? this.isLoading,
      currentHeight: currentHeight ?? this.currentHeight,
    );
  }

  
  List<Object?> get props => [address, positionIndex, globalKeys, isLoading];
}
Cubit

Cubit을 생성해주고, 페이지 진입시에 started() 함수를 호출하도록 하겠다. 해당 함수에서는 데이터를 세팅해주기 위한 함수이며, 여기서 중요한 부분은 바로 반환 받은 address의 길이만큼 GlobalKey를 생성해야 한다는 것이다.

왜 GlobalKey를 사용해야 할까 ?

자 UI의 구조를 보면 좌측에 "시" 정보가 우측에는 "구" 아래에 "동" 정보가 같이 있는 것을 볼 수 있다. 이렇게 되면 우측의 ListView안에 객체의 높이 값이 달라지기에 각 객체의 높이 값 정보를 가져오기 위해서 필요하고, 또 하나의 이유는 우측의 "시"를 터치하였을 때에도 좌측의 스크롤 포지션의 포커싱을 자동으로 맞춰줘야 하기에 GlobalKey를 사용해야만 한다.

class AddressStepCubit extends Cubit<AddressStepState> {
  final AppAddressRepository _repository = AppAddressRepository.instance;
  AddressStepCubit() : super(const AddressStepState());

  Future<void> started() async {
    List<AddressStepModel> _address = await _repository.assetsJsonToAddress();
    emit(state.copyWith(
      address: _address,
      globalKeys: List.generate(_address.length, (_) => GlobalKey()),
    ));
  }
}

이번에는 NotificationListener에서 ScrollUpdateNotification 객체를 받아와 스크롤 변화시에 상태를 변경해주는 로직을 작성해 보도록 하겠다.

NotificationListener는 스크롤 변경시 호출되는 리스너로 ScrollUpdateNotification을 받아오도록 하였다.

스크롤 포지션에 의해 시 정보의 포커싱을 변경해 주는 로직이다.

여기서 보면 Globalkey를 사용하여 해당 위젯의 사이즈를 받아와 사이즈의 위젯 보다 크면 다음 "시"로 포커싱을 넘기고 다시 위젯 사이즈에 맞게 포지션이 들어오면 변경해주고 있다.

해당 코드는 실제로 작동하면서, 로직을 이해하시는게 더 빠를 것 같다. 아래의 로직 외에도 다양한 방법으로 개발할 수 있으니 참고용으로 사용하시길 바란다.

Future<void> scrollListener(ScrollUpdateNotification noti) async {
    if (state.globalKeys != null) {
      if (state.globalKeys![state.positionIndex].currentContext != null) {
        double _widgetHeight =
            state.globalKeys![state.positionIndex].currentContext!.size!.height;
        double _currentPixel = noti.metrics.pixels;
        if ((_widgetHeight + state.currentHeight) -
                ((state.positionIndex + 1) * 50 - 12) <
            _currentPixel) {
          if (!state.isLoading) {
            emit(state.copyWith(isLoading: true));
            emit(state.copyWith(
                isLoading: false,
                positionIndex: state.address!.length - 2 < state.positionIndex
                    ? state.positionIndex
                    : state.positionIndex + 1,
                currentHeight: state.address!.length - 2 < state.positionIndex
                    ? state.currentHeight
                    : _widgetHeight + state.currentHeight));
          }
        } else if (state.currentHeight - ((state.positionIndex * 50) + 12) >
            _currentPixel) {
          emit(state.copyWith(isLoading: true));
          int _changeIndex = 0;
          double _changeHeight = 0;
          if (state.positionIndex - 1 > 0) {
            _changeIndex = state.positionIndex - 1;
            _changeHeight =
                state.globalKeys![_changeIndex].currentContext!.size!.height;
          } else {
            _changeHeight = state.globalKeys![0].currentContext!.size!.height;
          }
          emit(state.copyWith(
              isLoading: false,
              positionIndex: _changeIndex,
              currentHeight: state.currentHeight - _changeHeight < 0
                  ? 0
                  : state.currentHeight - _changeHeight));
        }
      }
    } else {
      emit(state.copyWith(
          globalKeys:
              List.generate(state.address!.length, (_) => GlobalKey())));
    }
  }

마지막으로 좌측에 있는 "시" 탭을 터치 했을 때, 우측의 동일한 "시"에 해당하는 "구"/"동"의 위젯의 포커싱을 맞추기 위해 필요한 로직이다.

 Future<void> tapToScrollAnimated({
    required int index,
    required ScrollController controller,
  }) async {
    if (state.globalKeys != null) {
      double _height = 0.0;
      for (int i = 0; i < index; i++) {
        if (state.globalKeys![i].currentContext != null) {
          _height = _height + state.globalKeys![i].currentContext!.size!.height;
        }
      }

      controller.animateTo(_height - 50 * (index - 1) - 29,
          duration: const Duration(milliseconds: 300), curve: Curves.ease);
    }
  }

Presentation

UI 전체 코드이다.


class AddressStepScreen extends StatelessWidget {
  final ScrollController controller = ScrollController();
  AddressStepScreen({super.key});

  
  Widget build(BuildContext context) {
    return BlocProvider<AddressStepCubit>(
      create: (_) => AddressStepCubit()..started(),
      child: BlocBuilder<AddressStepCubit, AddressStepState>(
        builder: (context, state) {
          if (state.address == null) {
            return const Scaffold(
              body: Center(
                child: CircularProgressIndicator(
                  color: Colors.amber,
                ),
              ),
            );
          } else {
            return Stack(
              children: [
                Scaffold(
                  appBar: appBar(title: "Step Address"),
                  body: Row(
                    children: [
                      SizedBox(
                        width: 130,
                        child: ListView.builder(
                            itemCount: state.address!.length,
                            itemBuilder: (context, index) {
                              return GestureDetector(
                                onTap: () => context
                                    .read<AddressStepCubit>()
                                    .tapToScrollAnimated(
                                        index: index, controller: controller),
                                child: Container(
                                  color: state.positionIndex == index
                                      ? const Color.fromRGBO(51, 51, 51, 1)
                                      : const Color.fromRGBO(31, 31, 31, 1),
                                  width: 110,
                                  height: 50,
                                  child: Center(
                                      child: Text(
                                    state.address![index].name,
                                    style: const TextStyle(
                                        fontWeight: FontWeight.bold,
                                        fontSize: 16),
                                  )),
                                ),
                              );
                            }),
                      ),
                      Expanded(
                          child: NotificationListener<ScrollUpdateNotification>(
                        onNotification: (notification) {
                          context
                              .read<AddressStepCubit>()
                              .scrollListener(notification);
                          return false;
                        },
                        child: SingleChildScrollView(
                          controller: controller,
                          child: Column(
                            children: [
                              ...List.generate(
                                  state.address!.length,
                                  (index) => Container(
                                        key: state.globalKeys![index],
                                        child: Column(
                                          children: [
                                            Column(
                                              crossAxisAlignment:
                                                  CrossAxisAlignment.start,
                                              children: [
                                                SizedBox(
                                                  width:
                                                      MediaQueryData.fromWindow(
                                                                  window)
                                                              .size
                                                              .width -
                                                          130,
                                                  height: 64,
                                                  child: Padding(
                                                    padding:
                                                        const EdgeInsets.only(
                                                            left: 16,
                                                            bottom: 8),
                                                    child: Row(
                                                      crossAxisAlignment:
                                                          CrossAxisAlignment
                                                              .end,
                                                      children: [
                                                        Text(
                                                          state.address![index]
                                                              .name,
                                                          style:
                                                              const TextStyle(
                                                                  fontWeight:
                                                                      FontWeight
                                                                          .bold,
                                                                  fontSize: 18,
                                                                  color: Colors
                                                                      .amber),
                                                        ),
                                                      ],
                                                    ),
                                                  ),
                                                ),
                                                ...List.generate(
                                                    state.address![index].names
                                                        .length,
                                                    (i) => SizedBox(
                                                          height: 40,
                                                          child: Padding(
                                                            padding:
                                                                const EdgeInsets
                                                                        .only(
                                                                    left: 16),
                                                            child: Row(
                                                              crossAxisAlignment:
                                                                  CrossAxisAlignment
                                                                      .center,
                                                              mainAxisAlignment:
                                                                  MainAxisAlignment
                                                                      .start,
                                                              children: [
                                                                Text(state
                                                                    .address![
                                                                        index]
                                                                    .names[i]
                                                                    .name),
                                                              ],
                                                            ),
                                                          ),
                                                        ))
                                              ],
                                            ),
                                            if (index ==
                                                state.address!.length - 1) ...[
                                              const SizedBox(height: 100),
                                            ],
                                          ],
                                        ),
                                      )),
                            ],
                          ),
                        ),
                      )),
                    ],
                  ),
                ),
                GestureDetector(
                  onTap: () => controller.animateTo(0,
                      duration: const Duration(milliseconds: 350),
                      curve: Curves.ease),
                  child: Container(
                    width: MediaQueryData.fromWindow(window).size.width,
                    height: MediaQueryData.fromWindow(window).padding.top,
                    color: Colors.white.withOpacity(0.01),
                  ),
                ),
              ],
            );
          }
        },
      ),
    );
  }
}

Result

Git

https://github.com/boglbbogl/flutter_velog_sample/tree/main/lib/app/address/step

마무리

주소 검색과 관련된 기능을 개발해야 해서 이것저것 재미로 만들어보다가 작성하게된 글인데, 급하게 만들어서 글을 작성하고 개발 로직을 글로 표현하는데 한계가 있어 자세한 설명이 많이 빠진 것 같다.

Git에서 파일 받아서 한 번 실행해보시면 쉽게 이해할 수 있으니, 실행해 보시는걸 추천합니다.

profile
Flutter Developer

0개의 댓글