Flutter

구글 맵 3단계 마커 활용

whs5758 2025. 8. 20. 17:35

마커(Marker)란

구글 맵에서 중요한 장소나 관심있는 위치를 표시하는 용도로 사용합니다.

  • 위치 표시: 정확한 위도, 경도로 특정 장소를 가리킴
  • 정보 제공: 마커를 탭하면 그 장소에 대한 정보를 보여줄 수 있음.
  • 개인화: 사용자가 직접 원하는 곳에 마커를 추가하고 저장할 수 있습니다.

Marker 객체의 구조

Marker(
  markerId: MarkerId('unique_id'),     // 마커 구분용 고유 ID
  position: LatLng(37.5665, 126.9780), // 마커가 표시될 위치
  infoWindow: InfoWindow(              // 마커를 탭했을 때 나타나는 정보창
    title: '서울시청',
    snippet: '서울특별시의 중심',
  ),
)

여러 개의 마커를 관리하려면 Set<Marker> 타입을 권장

Set<Marker> _markers = {}; // 마커들을 담을 집합

 

왜 List가 아닌 Set일까?

  • 중복 방지: 같은 ID의 마커가 여러 개 생기는 걸 막아줌.
  • 효율적인 관리: 마커 추가/삭제가 빠름.

1단계 예제에 마커 객체만 올려 보기

 

1단계 - 모델링 과 캐시 기능 추가해보기 , 팩토리 생성자 사용해보기
// 저장 시킬 마커 정보를 담는 클래스 (추후 확장 가능)

class SavedMaker {
  final String id;
  final String name;
  final double latitude;
  final double longitude;
  // GSP 좌표 정보 ...

  // 생성자에는 ?? if 리터키워드가 없다.

  // _ <-- private <-- 외부에서 생성자 호출 방법이 없다.
  SavedMaker._internal({
    required this.id,
    required this.name,
    required this.latitude,
    required this.longitude,
  });

  // 1. static 저장소(캐시) 준비
  static final Map<String, SavedMaker> _cache = <String, SavedMaker>{};

  // 팩토링 생성자
  factory SavedMaker.fromMap(Map<String, dynamic> map) {
    String id = map["id"] as String;

    if (_cache.containsKey(id)) {
      print("캐시에 저장된 객체 리턴 및 활용");
      // map 구조에 있는 객체를 반환
      return _cache[id]!;
    }

    final newMarker = SavedMaker._internal(
      id: id,
      name: map['name'],
      latitude: map['latitude'],
      longitude: map['longitude'],
    );

    _cache[id] = newMarker;

    return newMarker;
  }

  // SavedMaker 를 SharedPreferences 에 저장 할 형태로 사용하기 위해 설계
  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'name': name,
      'latitude': latitude,
      'longitude': longitude,
    };
  }
  // Map<String, dynamic> map = SavedMaker().toMap();
}

 

StorageService 서비스 클래스 구현 - 마커 CRUD 담당
// SharedPreferences 를 가공해서 관리하는 서비스 클래스
import 'dart:convert';

import 'package:class_google_map_v1/models/saved_marker.dart';
import 'package:shared_preferences/shared_preferences.dart';

class StorageService {
  // SharedPreferences - 저장 키
  static const String _markerKey = "saved_markers";

  // C R U D
  /// SavedMarker 들을 객체를 저장하는 기능 설계
  Future<bool> saveMarkers(List<SavedMaker> markers) async {
    try {
      final prefs = await SharedPreferences.getInstance();

      // [ SavedMarker(),SavedMarker(), SavedMarker(), ]
      // "key1" : 1,  "key2" : "abc",  "key3" : true, "key4" : SavedMarker (x)
      // dart 에서 json 형식을 만들 때 map 구조에서 변경하는게 가장 편하다.
      // {'key1' : 1,  'key2' : "abc"} --> jsonEncode(map)  --->   {"key1" : 1,  "key2" : "abc"}
      // 자료구조에서 map 메서드는 (내부 형태를 다른 형태로 바꿀 때 편하게 사용하는 메서드)
      final markersMapList = markers.map((e) => e.toMap()).toList();
      // [ {'id' : "1"},  {'id' : "3"}, {'id' : "3"},] ; --> List<Map<>>
      // List<Map> 형태를 json 형식으로 변환
      final jsonString = jsonEncode(markersMapList);
      return await prefs.setString(_markerKey, jsonString); // true
    } catch (e) {
      return false;
    }
  }

  /// 저장된 마커를 불러오기
  Future<List<SavedMaker>> loadMarkers() async {
    try {
      final prefs = await SharedPreferences.getInstance();

      // 저장되어 있는 JSON 형식에 문자열 가져오기
      final markersJson = prefs.getString(_markerKey);

      if (markersJson != null) {
        final List<dynamic> markersList = jsonDecode(markersJson);
        // [ {'id' : "1"},  {'id' : "2"} ]
        // 내부에 데이터 타입(형태를 변경할 때 쓰는 녀석)
        return markersList.map((e) => SavedMaker.fromMap(e)).toList();
        // [ SavedMaker(),  SavedMaker() ]  변경 됨
      }
      return [];
    } catch (e) {
      return [];
    }
  }

  /// 모든 SavedMarker 를 삭제하는 기능 추가
  Future<bool> clearAllMarkers() async {
    try {
      final prefs = await SharedPreferences.getInstance();
      // "saved_markers" : "[ {'id' : "1"},  {'id' : "3"}, {'id' : "3"},]"
      return await prefs.remove(_markerKey);
    } catch (e) {
      return false;
    }
  }

  /// 1단계 마커 객체 하나는 추가하는 코드를 작성해 보시요 (중복 체크)
  Future<bool> addMarker(SavedMaker newMarker) async {
    try {
      // 1. {...} 데이터를 가져와야 한다.
      // 2. {... } + {..} 기본 있더 데이터에 새롭게 추가 한다.
      // 3. Sp 에 다시 리스트 통으로 저장하기

      /// List 객체가 새롭게 추가 된 상태
      final List<SavedMaker> markers = await loadMarkers();

      /// 중복 체크 Stream API 를 활용하면 편리하다.
      // 자료구조에 any() 메서드 사용해보기
      /// markers.any() <-- 조건을 만족하는 요소가 하나라도 있으면 true 반환 한다.
      if (markers.any((marker) => marker.id == newMarker.id)) {
        print('이미 존재하는 마커입니다');
        return false;
      }
      // 새 마커를 리스트 자료구조에 추가하는 코드
      markers.add(newMarker);
      // 리스트 객체 통으로 다시 SharedPreferences 에 저장하는 코드
      return await saveMarkers(markers);
    } catch (e) {
      return false;
    }
  }

  /// 특정 ID 값으로 마커객체 하나를 삭제하는 코드를 만들어 보시오.
}

 

저장시 데이터 구조의 이해 확인
// SavedMarker 리스트 형태 

List<SavedMarker> markers = [
  SavedMarker(
    id: 'marker_001',
    title: '스타벅스 강남점',
    latitude: 37.4979,
    longitude: 127.0276,
   
  ),
  SavedMarker(
    id: 'marker_002', 
    title: '롯데월드타워',
    latitude: 37.5125,
    longitude: 127.1025,
  ),
];

---------------------------------------------

 // markers.map((marker) => marker.toMap()).toList()
 // SavedMarker 객체를 map 형태로 변환하고 그 map 를 리스로 감싼는 형태의 자료 구조 
 [
  {
    'id': 'marker_001',
    'title': '스타벅스 강남점', 
    'latitude': 37.4979,
    'longitude': 127.0276,
  },
  {
    'id': 'marker_002',
    'title': '롯데월드타워',
    'latitude': 37.5125,
    'longitude': 127.1025,
  }
]

----------------------------------------------------

// jsonEncode(....) 값 부분을 문자열 형태로 변환(json 형식) 
"saved_markers" : "[{\"id\":\"marker_001\",\"title\":\"스타벅스 강남점\"...}]"

 

2단계 - 마커 저장 및 불러오기 및 전체 삭제 기능 추가

 

import 'package:class_google_map_v1/models/saved_maker.dart';
import 'package:class_google_map_v1/screens/marker_list_screen.dart';
import 'package:class_google_map_v1/services/storage_service.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';

class MapScreen extends StatefulWidget {
  const MapScreen({super.key});

  @override
  State<MapScreen> createState() => _MapScreenState();
}

class _MapScreenState extends State<MapScreen> {
  // 구글맵 사용해보기 sdk 버전
  // 1단계 - 지도를 조작하기 위한 리모컨 필요 - GoogleMapController
  GoogleMapController? _controller;

  // 특정 위치로 부드럽게 이동 처리
  // 현재 줌 레벨 조정 1 ~ 20 (확대, 축소)
  // 1 - 전세계 지도. 15 - 도시 레벨
  // 화면의 중심 좌표 가져오기

  // 2 - 중심 좌표 객체 생성
  static const LatLng _initCenter = LatLng(35.162439, 129.062293);

  Set<Marker> _markerSet = {};
  final StorageService _storageService = StorageService();

  // SP에 저장되어 있던 데이터를 담을 자료구조
  List<SavedMaker> _savedMarkers = [];

  @override
  void initState() {
    super.initState();
    _loadMarkers();
  }

  /// 저장된 마커들을 불러오기
  Future<void> _loadMarkers() async {
    try {
      final List<SavedMaker> loadedMakers = await _storageService.loadMarkers();

      setState(() {
        _savedMarkers = loadedMakers;
        _markerSet.clear(); // 기존에 있던 마커를 다 제거

        for (SavedMaker m in loadedMakers) {
          _markerSet.add(
            Marker(
              markerId: MarkerId(m.id),
              position: LatLng(m.latitude, m.longitude),
              infoWindow: InfoWindow(
                title: m.name,
                snippet: "나의 장소",
              ),
            ),
          );
        }
      });
    } catch (e) {
      _showMessage('마커를 불러오는데 실패');
    }
  }

  // 스낵바 메세지 만들어보기
  void _showMessage(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        duration: const Duration(seconds: 1),
      ),
    );
  }

  void _onMapTapped(LatLng position) {
    final nameController = TextEditingController();
    showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: const Text('새 장소 추가'),
          content: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TextField(
                controller: nameController,
                decoration: InputDecoration(
                  labelText: '장소 이름',
                  hintText: '장소 이름을 입력하세요',
                  border: OutlineInputBorder(),
                ),
                autofocus: true,
              ),
            ],
          ),
          actions: [
            TextButton(
              onPressed: () {
                Navigator.pop(context);
              },
              child: Text('취소'),
            ),
            TextButton(
              onPressed: () {
                Navigator.pop(context);
                _addMarker(position, nameController.text);
              },
              child: Text('추가'),
            )
          ],
        );
      },
    );
  }

  void _addMarker(LatLng position, String name) {
    // 고유 ID 생성
    final markerId = DateTime.now().millisecond.toString();

    // 새로운 SavedMarker 객체 생성
    final newMarker = SavedMaker.fromMap(
      {
        'id': markerId,
        'name': name,
        'latitude': position.latitude,
        'longitude': position.longitude,
      },
    );
    setState(() {
      // 새롭게 생성한 객체를 마커 List 자료구조에 넣기
      _savedMarkers.add(newMarker);
      // 지도에 마커 추가
      _markerSet.add(
        Marker(
            markerId: MarkerId(markerId),
            position: position,
            infoWindow: InfoWindow(
              title: name,
              snippet: '저장된 장소',
            )),
      );
    });
    // 로컬에 저장
    _saveMarkers();
  }

  Future<void> _saveMarkers() async {
    try {
      final success = await _storageService.saveMakers(_savedMarkers);
      if (success) {
        _showMessage("마커 저장 완료");
      } else {
        _showMessage("마커 저장 실패");
      }
    } catch (e) {
      _showMessage("마커 저장 실패");
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.blue[400],
        foregroundColor: Colors.white,
        title: Text(
          '처음 만나는 구글 맵',
          style: TextStyle(
            fontWeight: FontWeight.bold,
          ),
        ),
        actions: [
          IconButton(
            onPressed: () {
              _showMarkerList();
            },
            icon: Icon(Icons.list),
          ),
          IconButton(
            onPressed: () async {
              await StorageService().clearAllMakers();
              setState(() {
                _markerSet = {};
                _savedMarkers = [];
              });
            },
            icon: Icon(Icons.delete_forever),
          ),
        ],
      ),
      // 3 - GoogleMap 위젯 사용
      // GoogleMap - 지도의 모든 것을 담을 수 있는 그릇
      body: GoogleMap(
        onMapCreated: (controller) {
          // controller 새롭게 생성된 지도 안에 생성한 GoogleController
          // 내 코드에서 사용하기 위해 멤버 변수로 선언
          // 주소값을 연결 시켜 두었다.
          _controller = controller;
        },
        initialCameraPosition: CameraPosition(
          target: _initCenter,
          zoom: 15,
        ),
        markers: _markerSet,
        onTap: _onMapTapped,
      ),
      bottomNavigationBar: Container(
        color: Colors.blue[200],
        padding: EdgeInsets.all(16.0),
        child: Text(
          '마커된 장소 ${_savedMarkers.length} 개',
          style: TextStyle(
            fontSize: 18.0,
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
    );
  }

  // 마커 목록 화면 표시 메서드
  void _showMarkerList() async {
    if (_savedMarkers.isEmpty) {
      _showMessage('저장된 장소가 없습니다');
    }

    /// 중요!! 화면 이동 후 코드가 여기서 멈춰져 있다. await
    // 저장된 마커들이 있다면 화면을 stack 구조로 올릴 예정
    // <> 타입 안정성을 위해 제네릭 사용
    final result =
        await Navigator.of(context).push<SavedMaker>(MaterialPageRoute(
      builder: (context) {
        return MarkerListScreen();
      },
    ));

    if (result != null) {
      // 리스트 화면에서 선택된 마커로 포커스를 이동하는 기능
      await _moveToMarker(result);
    }

    // 목록에서 돌아온 후 마커를 다시 로드 (새로고침 - 삭제된 마커가 있다면 반영)
    await _loadMarkers();
  }

  // 메서드 추가
  /// 코드로 특정 마커 위치로 이동하는 기능
  Future<void> _moveToMarker(SavedMaker marker) async {
    // 구글 맵에서 특정 포지션으로 이동시키는 기능은 _controller 담당
    if (_controller != null) {
      _controller!.animateCamera(
        CameraUpdate.newLatLngZoom(
            LatLng(marker.latitude, marker.longitude), 16.0),
      );
    }
  }
}

 

'Flutter' 카테고리의 다른 글

블로그 프로젝트 - 디자인 시안, 구조, 주소 수정  (0) 2025.08.20
MVVM 패턴과 상태 관리  (0) 2025.08.20
SharedPreferences  (0) 2025.08.20
구글 Map API 사용  (0) 2025.08.20
카메라 및 이미지 다루기 4단계  (0) 2025.08.20