[DU벅이] 화면에 지도 띄우기, 줌, 드래그 구현
지도 이미지 띄우기
지도 제작은 완료했으니 이제 지도를 화면에 띄우기만 하면 된다.
그냥 지도 이미지를 띄우는 것은 어렵지 않으나 지도 앱의 역할을 하기 위해선
줌, 드래그와 같은 기능을 가지고 있어야 했다. 따라서 위젯 트리를 아래와 같은 순서로 구성했다.
MaterialApp: Material Design을 사용할 수 있게 하는 클래스
Scaffold: Material Design 구조를 구현하는데 사용되는 위젯
GestureDetector: 줌, 드래그와 같은 제스처를 감지하는 위젯
Transform.scale: 자식 위젯의 크기를 확대 또는 축소 시켜주는 위젯 (줌)
Transform.translate: 자식 위젯의 위치를 이동시켜주는 위젯 (드래그)
CustomPaint: 경로를 표시하기 위해 위젯에 선을 그려주는 클래스
Image.asset: 지도 이미지
줌, 드래그 구현
지도를 확대/축소하고 드래그하기 위해 GestureDetector, Transform.scale, Transform.translate를 이용했다.
동작 감지: GestureDetector
GestureDetector 위젯은 사용자의 제스처를 감지한다.
줌을 했다면 어느 정도 줌을 했는지, 드래그를 했다면 어느 정도 드래그를 했는지 정보를 담아주는 역할을 한다.
지도 이미지 위젯을 줌/드래그하기 위해서 GestureDetector의 아래에 지도 이미지 위젯을 위치시켰다.
GestureDetector의 콜백 함수인 onScaleStart, onScaleUpdate를 이용하여 여러 기능들을 구현하는 것이 가능하다.
위젯 이동 시키기: Transform
Transform은 자식 위젯의 형태, 사이즈, 위치를 변경해 주는 위젯이다.
Transform.scale의 scale 속성의 값을 변경하면 위젯의 크기를 수정 가능하고(줌 인/줌 아웃)
Transform.translate의 Offset 속성의 값을 변경하면 위젯의 위치를 변경이 가능하다(드래그)
scale 속성의 값과 Offset 속성의 값을 각각 변수로 지정하면 내가 원하는 때에 원하는 만큼 줌/드래그가 가능해진다.
원래 translate의 Offset은 위젯 중심을 기준으로 Offset(0, 0)을 갖는다.
Offset(50, 0)으로 설정하면 오른쪽으로 50만큼 위젯이 이동하고
Offset(0, 50)으로 설정하면 아래쪽(주의)으로 50만큼 위젯이 이동한다.
하지만 DU벅이는 큰 지도 이미지를 사용하고 대부분 위젯의 크기가 화면에 보이는 부분의 크기보다 큰 상황(줌)이 많을 것이므로
개발의 편의를 위해 Offset의 중심점(원점)을 위젯의 중심이 아닌 이미지의 좌표 기준과 동일하게 좌측 상단으로 이동시켰다.
이렇게 하면 Offset을 마치 이미지 상의 현재 위치(좌표)처럼 사용이 가능하다.
아래 내용은 중심점을 변환시키는 코드이다.
Transform.scale(
scale: mapvalue.scale,
child: Transform.translate(
offset: Offset(_imageWidth_du / 2 - mapvalue.position.dx, _imageHeight_du / 2 + scr_img_diff / 2 - mapvalue.position.dy).scale(scale_offset, scale_offset)
_imageWidth_du, _imageHeight_du는 지도 이미지의 너비, 높이이다.
현재 DU벅이는 3000 x 5333 이미지를 사용 중이므로
_imageWidth_du = 3000
_imageHeight_du = 5333 이다.
scr_img_diff는 지도 이미지 위젯의 높이와 디바이스 전체 화면 높이 간의 차이이다.
scr_img_diff / 2 를 더해 지도 이미지 위젯의 중앙과 디바이스 화면 중앙을 맞춰주었다.
(scr_img_diff를 어떻게 구하는지는 하단에 설명 예정)
mapvalue.position.dx, mapvalue.position.dy는 줌/드래그 기능을 위해 지정한 변수이다.
아래 사진을 통해 변환 과정을 나타냈다.

이를 통해 mapvalue.position.dx, mapvalue.position.dy에 원하는 값을 넣으면
위젯을 알맞게 이동시키며 해당 값에 해당하는 지도 이미지 픽셀을 화면 중앙에 표시하게 된다.
ex)
mapvalue.position.dx = 500
mapvalue.position.dy = 1300
갤럭시 S23의 경우 scr_img_diff = 1167
Offset(_imageWidth_du / 2 - mapvalue.position.dx,
_imageHeight_du / 2 + scr_img_diff / 2 - mapvalue.position.dy)
= Offset( 3000 / 2 - 500, 5333 / 2 + 1167 / 2 - 1300 )
= Offset( 1000, 1950 )
= 지도 이미지 위젯을 x축 1000만큼, y축 1950만큼 이동시킨다.

위 사진과 같이 화면 중앙에 이미지의 ( 500, 1300 ) 픽셀이 잘 위치하게 된다는 것을 확인할 수 있다.
줌, 드래그 기능 구현하기: onScaleStart, onScaleUpdate
위에서도 설명했듯이 onScaleStart, onScaleUpdate는 GestureDetector의 콜백함수이다.
onScaleStart는 사용자가 제스처를 시작할 때 호출되는 함수이다.
매개변수로 ScaleStartDetails details를 받는데
이 details는 줌/드래그가 시작된 위치와 시간 등의 정보를 가진다.
아래와 같이 제스처가 시작되는 순간의 줌 정도와 현재의 위치를 저장하도록 했다.
// 줌/드래그 가 시작되는 시점의 줌 레벨과 위치를 저장하는 함수 _onScaleStart
void _onScaleStart(ScaleStartDetails details) {
mapvalue.previousScale = mapvalue.scale;
mapvalue.previousPosition = details.focalPoint;
}
onScaleUpdate는 사용자가 제스처를 진행 중일 때 호출되는 함수이다.
역시 매개변수로 ScaleUpdateDetails details를 받고
이 details는 줌 변화량과 중심 위치등의 정보를 가진다.
먼저 아래와 같이 줌 레벨의 정도를 업데이트한다.
mapvalue.scale = (mapvalue.previousScale * details.scale).clamp(1.3, 12.0);
onScaleStart에서 저장해둔 이전의 줌 레벨 값에 바뀐 줌 레벨 정도를 곱해서 새로운 줌 레벨을 저장한다.
다음은 드래그 기능이다.
드래그는 사용자가 화면을 드래그하는 정도와 위젯이 이동하는 변화량이 같게 만들면 구현이 가능하다.
즉 details가 가지고 있는 중심 위치(사용자가 화면을 드래그해서 바뀐 중심 위치)에서
기존의 위치 좌표를 빼면 화면을 이동한 정도가 나오고 그 값을 좌표 변수에 빼주면 된다.
(위젯을 50만큼 이동시키기 위해서는 좌표가 -50이 되어야 하므로 ‘-=’ 연산자를 사용)
하지만 주의해야 할 점은 줌 레벨에 따라 드래그의 변화량도 변한다는 것이다.
화면이 확대되어 있는 상태와 확대되어 있지 않은 상태에서 한 번의 드래그 변화량은 차이가 크다.
따라서 그 변화량을 반드시 줌 레벨 값으로 나누어서 보정을 해주어야한다.
mapvalue.position -= (details.focalPoint - mapvalue.previousPosition) / mapvalue.previousScale / scale_offset;
scale_offset은 좌표를 이미지의 픽셀 단위와 같도록 보정해 주는 비율값이다.
이미지 위젯을 화면 너비에 꽉 차도록 설정했기 때문에 디바이스 해상도 너비에서 이미지의 너비를 나누어서 비율을 계산했다.
디바이스 너비 / 이미지 너비 = scale_offset 이고 이는
디바이스 너비 / scale_offset = 이미지 너비 와 같으므로
실제 디바이스 드래그 변화량 / scale_offset = 이미지 좌표 변화량이 된다.
드래그로 이미지 위젯 밖을 벗어나지 않도록 만들기: clamp
현재 DU벅이는 3000 x 5333 지도 이미지를 사용 중이다.
이 지도 이미지 위젯을 화면에 띄우고 드래그 기능을 사용하면 한 가지 문제가 발생한다.
계속 한 방향으로 드래그를 지속하면 결국 지도 이미지 영역 밖으로 벗어나고
위젯의 범위를 벗어나면 GestureDetector 영역 밖이기 때문에 어떠한 터치도 인식하지 못하게 된다.
따라서 드래그를 통해 이미지 범위 밖을 벗어나지 못하도록 좌표 위치에 제한을 두어야 한다.
이러한 제한을 가능하게 하는 것이 clamp 이다.
clamp는 Flutter에서 제공하는 메서드이며 숫자를 지정된 범위 내로 제한할 수 있게 해준다.
아래는 사용 예시이다.
double value = 15.0;
value = value.clamp(0.0, 10.0);
print(value); // 10.0 출력
화면에서 이미지 위젯이 벗어날 수 있는 경우의 수는 2가지이다.
-
보여지는 화면이 이미지 위젯보다 작으며 드래그를 통해 이미지 영역 밖으로 벗어나는 경우

위 사진과 같은 경우이다. 이 경우에 현재 보여지는 화면의 크기가 dx, dy의 최대/최소값을 결정한다.
좌상단이 Offset(0, 0) 이므로
최소 Offset( dx, dy ) = Offset( 화면 너비 / 2, 화면 높이 / 2 )
최대 Offset( dx, dy ) = Offset( _imageWidth_du - 화면 너비 / 2, _imageHeight_du - 화면 높이 / 2 )
이때 보이는 화면의 크기는 너비를 통해 계산할 수 있다.
위에서도 언급했지만 줌 레벨이 1.0일 경우 이미지 위젯을 화면 너비에 꽉 차도록 설정했기 때문에
_imageWidth_du를 줌 레벨 정도로 나누면 현재 화면에 보이는 너비를 픽셀 단위로 알 수 있다.
높이는 실제 해상도의 너비, 높이 비율을 구한 후 너비에 곱해주면 구할 수 있다.
실제 해상도의 너비, 높이 비율은 MediaQuery.of(context) 를 사용해서 구할 수 있다.
MediaQueryData는 디바이스의 화면 크기, 픽셀 비율, 텍스트 스케일 팩터,
픽셀 밀도, 상단 및 하단 패딩, 화면 방향 등의 정보를 제공하는 클래스이다.
위에서 언급한 scr_img_diff 또한 MediaQuery.of(context).padding.top 을 통해 구한 값이다. -
보여지는 화면이 이미지 위젯보다 커서 이미 화면에 이미지 영역 밖이 보여지는 경우
사실 일어나서는 안되는 경우이다.
이미지 위젯의 밖 영역은 Flutter에서 흰색 배경(아마 디폴트 색상)으로 표현해 주기 때문에
이러한 상황이 벌어지는 순간 앱의 완성도가 확연히 떨어져 보이게 된다.
줌 레벨에도 clamp를 걸어서 최대한 일어나지 않도록 했지만
디바이스에 따라 일어날 수도 있는 경우이기 때문에 고려하지 않을 수는 없었다.
이 경우에는 그래도 지도 이미지가 최대한 중간에 위치하는 것이 제일 좋은 방법이라고 생각했고
그것에 맞게 clamp를 설정했다.
쉽게 생각하면 dx, dy 각각의 최소값의 최대값, 최대값의 최소값을 지정해준 것이라고 생각하면 된다.

중앙을 유지하기 위해서 dx의 최소값이 최대 _imageWidth_du / 2, 최대값이 최소 _imageWidth_du / 2 이도록 설정했고
dy도 마찬가지로 최소값이 최대 _imageHeight_du / 2, 최대값이 최소 _imageHeight_du / 2 값을 갖도록 했다.
이와 같은 과정으로 지도 이미지를 띄우고 줌, 드래그 기능을 구현했다.
실제 개발을 할 때도 제일 시간이 많이 걸렸던 작업이기도 하고
오랜만에 보면 또 까먹는 구간이기도 해서 좀 더 자세하게 기록으로 남긴다.
댓글남기기