Flutter 실내지도를 구현하기 까지의 여정(2)

[Flutter] · 2024. 11. 9. 16:41

 

이미 앱은 배포가 완료된 상태라 전 편을 이어가지는 못하고 실내지도를 어떻게 구현했는지를 말해보겠다.

(그럼에도 불구하고 길어질듯 하다)

 

결론부터 말하자면 FlutterMap 패키지는 사용하지 않는다.

FlutterMap 위젯의 TileLayer에 AssetTileProvider로 실내 평면도를 구현하는 것까지는 괜찮았으나 그 안의 crs(좌표계)는 위도경도 기반 crs를 사용하여 평면도안에서 왜곡이 일어난다. 이러면 Marker를 써야하는 실내 평면도에서는 치명적인 오류가 된다.

 

그래서 다시 시작하기로 했다. 찾아보니 자식 위젯을 드래그, 확대/축소, 회전 등의 제스처로 조작할 수 있도록 하는 상호작용을 지원하는 InteractiveViewer위젯이 있었다.

Container(
  child: Stack(
    children: [
      // 자식위젯에 대한 줌(zoom), 드래그(drag)등의 상호작용을 가능케 하는 위젯
      InteractiveViewer(
        transformationController: _transformationController,
        panEnabled: true,
        // 팬(이동) 가능 여부
        scaleEnabled: true,
        // 확대/축소 가능 여부
        minScale: 1,
        // 최소 확대 비율
        maxScale: 7.0,
        // 최대 확대 비율

 

다음과 같이 Stack안에 InteractiveViewer를 띄워서 위에 탭바 레이어 와 층수레이어를 띄울 수 있게 했다.

상단의 탭바와 층수레이어

 

탭바와 층수 레이어의 구현은 나중에 다루도록 하겠다.

 

그리고 InteractiveViewer의 자식으로 스택 안의 백엔드로 부터 받아오는 이미지와, markerlist.map을 통해 평면도 위에 올린다. 

 

이때 백엔드에서 받아오는 마커의 (x,y)좌표는 피그마 좌표를 기준으로 받는다.

피그마에서 지도위의 마커를 클릭하면 나오는 (x,y)좌표를 백엔드에서 보낸다

class LocationInfo {
  final int socketId;
  final double xcoordinate;
  final double ycoordinate;
List<LocationInfo> markerLocationList = [];

 

따라서 다음과 같은 로직으로 좌표를 변환한다.
평면도는 항상 정사각형이고 한변의 길이는 mediaquery를 통해 screenWidth로 고정되기 때문에 피그마기준 지도의 한변이 812, 이므로 screenWidth/812로 변환후 미세한 조정을 한다.

//x,y 값을 화면에 맞게 변환하는 공식
Offset transformCoordinates(double x, double y, double screenWidth) {
  double transformedX = x * screenWidth / 812 - screenWidth * 0.0245;
  double transformedY = y * screenWidth / 812 - screenWidth * 0.014;
  return Offset(transformedX, transformedY);
}

 

child: Container(
  width: screenWidth,
  height: screenHeight,
  margin: EdgeInsets.only(top: screenHeight * 0.2),
  child: Stack(
    children: [
      // Floor plan image
      Image.network(
        floorImage, // 평면도 이미지 경로
        width: screenWidth,
      ),
      // Markers
      ...markerlist.map((markerInfo) {
        return Positioned(
          left: markerInfo.xcoordinate,
          top: markerInfo.ycoordinate,
          child: Transform.scale(
            scale: 1 / _scaleFactor, // 줌 배율에 반비례하도록 설정

 

여기서 marker가 줌배율에 반비례하여 자연스럽게 줄어들도록 코드를 짰다. 

 

InteractiveViewer(
  ...
  onInteractionUpdate: (details) {
    if (_isScaling || details.scale != 1.0) {
      setState(() {
        _scaleFactor = _lastScale * details.scale;
        _scaleFactor = _scaleFactor.clamp(1.0, 7.0);
        _isScaling = true;
      });
    }
  },
  //끝났을 때 마지막 줌 레벨을 기억함
  onInteractionEnd: (details) {
  	...
    setState(() {
      _lastScale = _scaleFactor;
    });
    _isScaling = false;
    
  },

 

InteractiveViewer에서 줌을 감지하여 현재의 줌레벨을 _scaleFactor(global 변수)에 저장하여 Transform의 scale 파라미터에 1/_scaleFactor로 하면 줌레벨이 증가할때 마커의 크기는 반비례하여 줄어든다.

 

주의할점은 줌이 끝났을때 마지막 줌 레벨을 _lastScale변수로 기억해야한다. 안하면 이 후 터치시에 크기가 원래대로 돌아와 버린다.

...markerlist.map((markerInfo) {
  return Positioned(
    left: markerInfo.xcoordinate,
    top: markerInfo.ycoordinate,
    child: Transform.scale(
      scale: 1 / _scaleFactor, // 줌 배율에 반비례하도록 설정
      child: GestureDetector(
          //마커의 클릭리스너
          onTap: () async {
            await _socketProvider.fetchGetEachSocket(
                markerInfo.socketId);
            int socketId =
                _socketProvider.socketIdResult.socketId;
            String socketName = _socketProvider
                .socketIdResult.socketName;
            String socketImage = _socketProvider
                .socketIdResult.socketImage;
            String buildingName = _socketProvider
                .socketIdResult.buildingName;
            String spaceName = _socketProvider
                .socketIdResult.spaceName;
            print('Marker Id: ${markerInfo.socketId}');
            setState(() {
              selectedMarkerId = markerInfo.socketId;
            });
            showbottomsheet(
                context,
                socketName,
                socketImage,
                buildingName,
                spaceName,
                socketId);
          },
          child: Transform.scale(
            scale:
                selectedMarkerId != markerInfo.socketId
                    ? 1.0
                    : 1.2,
            child: SvgPicture.asset(
              selectedMarkerId != markerInfo.socketId
                  ? 'assets/images/pin_consent_location.svg'
                  : 'assets/images/pin_consent_location_selected.svg',
              width: 36,
              height: 42,
            ),
          )),
    ),
  );
}).toList(),

 

이제 마커의 클릭리스너 콜백함수를 통해 클릭시 마커의 데이터가 포함된 bottomsheet를 올리는데 이때 꼭 showBottomSheet 함수로 구현해야 sheet가 올라와도 외부화면의 상호작용(클릭 및 드래그)가 가능하다.

마커 클릭시 선택 효과와 bottomsheet

socketId를 selectedMarkerId(global)에 저장하여 단일 선택이 가능하게끔 한다.

 

3편에서 계속