[Flutter/iOS] Geolocator 로 사용자 백그라운드 위치 가져오기 (Feat. Simulator location )

Cherrie KimCherrie Kim
5 min read

개요

현재 개발중인 프로젝트에서 사용자에게 위치 권한을 요청하고, 현재 위치를 가져와야 하는 기능이 필요해 Flutter Geolocator Plugin을 적용했다.

Geolocator는 다음과 같은 기능을 제공한다:

  • 기기의 마지막 위치와 현재 위치 가져오기

  • 현재 위치 추적

  • 위치 서비스 상태 확인과 권한 요청

  • 두 위치 간 거리 및 방향(방위각) 계산

자세한 내용은 [공식 문서] 참고

이 패키지는 안드로이드, iOS, macOS, Web, Windows에서 모두 활용 가능하지만, 이 글에서는 iOS 설정을 위주로 다뤄보려 한다.

초기 설정 (iOS 기준)

[1] ios/Runner/Info.plist 설정

<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs access to location when open.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs access to your location while in use.</string>
  • <string> 태그 안의 문자열은 사용자가 앱을 처음 실행할 때 위치 접근을 요청하는 팝업에 표시되니 앱 상황에 맞게 적절히 조정하면 된다

  • 나는 백그라운드 위치도 받아와야 하기 때문에 NSLocationWhenInUseUsageDescription 도 설정해줬다. 이걸 설정하는 경우 Apple이 앱 제출 시 이런저런 질문을 할 수 있다고 한다.

  • 특정 메소드들을 사용할 경우 Info.plist에 더 추가 해줘야 하는 것들이 있으니, 문서를 잘 읽어봐야 한다. 위처럼 설정했을 때 기본적인 기능을 사용하는 데는 문제가 없다.

[2] Xcode 설정

  1. Xcode에서 프로젝트를 연다 (플젝 자체를 통으로 열면 안되고 ios 폴더를 열어야 한다)

  2. "Project Targets" → "Runner" → "Sigining & Capabilities 로 이동

  3. "+ Capability를 눌러"Background Modes" 를 선택해준다 (더블클릭)

  4. 이후 "Background Modes"에서 Location updates를 체크해준다.

이렇게 설정할 경우, 앱을 앱스토어에 제출할 때 애플에게 사용자 위치 정보가 필요한 이유를 상세히 설명해줘야 한다. 만족할만한 설명을 주지 못하면 앱 제출이 거절될 수 있다 한다.

iOS 시뮬레이터 위치 설정

시뮬레이터에서 기기 위치를 수동으로 원하는 좌표로 설정할 수 있다. 이를 통해 Geolocator의 '사용자 위치 스트리밍' 기능이 잘 작동하는지 확인할 수 있었다.

위치 설정 방법은 다음과 같다:

  1. 시뮬레이터를 띄워둔 후 위 상단바(..?) 에서 Features → Location → Custom Location

  2. 원하는 위도/경도를 설정해준다

테스트 코드

Geolocator의 서비스들이 잘 돌아가는지 확인하기 위한 테스트 페이지를 만들었다.

대충 이런 식으로 생겼다. 버튼을 누르면 적절한 결과값이 밑에 글씨로 반환된다.

(못생김 주의,, 대충 짬 주의,,,)

누군가에겐 도움이 될 수 있지 않을까 싶어 코드를 첨부한다!

// geolocator_test_page.dart

import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'dart:async';
import '../geolocator_service.dart';


class GeolocatorTestPage extends StatefulWidget {
  const GeolocatorTestPage({Key? key}) : super(key: key);

  @override
  _GeolocatorTestPageState createState() => _GeolocatorTestPageState();
}

class _GeolocatorTestPageState extends State<GeolocatorTestPage> {
  String _output = '버튼 클릭 ㄱㄱ';
  List<String> _locationLog = [];
  StreamSubscription<Position>? _positionStreamSubscription;
  StreamSubscription<ServiceStatus>? _serviceStatusStreamSubscription;

  @override
  void dispose() {
    // Cancel the stream subscriptions when the widget is disposed
    _positionStreamSubscription?.cancel();
    _serviceStatusStreamSubscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Geolocator Service Test'),
        elevation: 2,
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            btn(
              onPressed: _getCurrentPosition,
              child: '현재 위치 불러오기',
            ),
            btn(
              onPressed: _getLastKnownPosition,
              child: '마지막으로 알고있는 위치 불러오기',
            ),
            btn(
              onPressed: _startListeningToLocationUpdates,
              child: 'Location Update Listening 시작',
              color: Colors.black,
            ),
            btn(
              onPressed: _stopListeningToLocationUpdates,
              child: 'Location Update Listening 끝',
            ),
            btn(
              onPressed: _checkLocationServiceStatus,
              child: 'Location Service 상태 확인',
            ),
            btn(
              onPressed: _checkPermissionStatus,
              child: '위치 권한 상태 확인 - always 여야함',
            ),
            btn(
              onPressed: _requestPermission,
              child: 'Permission 요청',
            ),
            btn(
              onPressed: _checkLocationAccuracy,
              child: 'Location 정확도 확인',
            ),
            btn(
              onPressed: _listenToServiceStatusStream,
              child: 'Listen to Service Status Changes',
              color: Colors.green,
            ),
            btn(
              onPressed: _openSettings,
              child: '위치 권한 수정',
              color: Colors.red,
            ),
            SizedBox(height: 20),
            Expanded(
              child: SingleChildScrollView(
                child: Text(
                  _output,
                  style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                ),
              ),
            ),
            SizedBox(height: 20),
            Expanded(
              child: ListView.builder(
                itemCount: _locationLog.length,
                itemBuilder: (context, index) {
                  return Text(
                    _locationLog[index],
                    style: TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                        color: Colors.green),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }

  void _setOutput(String text) {
    setState(() {
      _output = text;
    });
  }

  void _logLocation(String text) {
    setState(() {
      _locationLog.add(text);
    });
  }

  Future<void> _getCurrentPosition() async {
    try {
      Position position = await GeolocatorService.getCurrentPosition();
      _setOutput(
          'Current Position: ${position.latitude}, ${position.longitude}');
    } catch (e) {
      _setOutput('Error: $e');
    }
  }

  Future<void> _getLastKnownPosition() async {
    try {
      Position? position = await GeolocatorService.getLastKnownPosition();
      if (position != null) {
        _setOutput(
            'Last Known Position: ${position.latitude}, ${position.longitude}');
      } else {
        _setOutput('No Last Known Position available.');
      }
    } catch (e) {
      _setOutput('Error: $e');
    }
  }

  void _startListeningToLocationUpdates() {
    _positionStreamSubscription = GeolocatorService.getPositionStream(
      accuracy: LocationAccuracy.high,
      distanceFilter: 10,
    ).listen(
      (Position position) {
        final positionString =
            'Position Update: ${position.latitude}, ${position.longitude}';
        _setOutput(positionString);
        _logLocation(positionString);

        debugPrint(
            'Position Update: ${position.latitude}, ${position.longitude}');
      },
      onError: (e) {
        _setOutput('Error: $e');
      },
      onDone: () {
        _setOutput('Location stream closed.');
      },
    );
  }

  void _stopListeningToLocationUpdates() {
    _positionStreamSubscription?.cancel();
    _setOutput('Stopped listening to location updates');
  }

  Future<void> _checkLocationServiceStatus() async {
    bool isEnabled = await GeolocatorService.isLocationServiceEnabled();
    _setOutput('Location Service Enabled: $isEnabled');
  }

  Future<void> _checkPermissionStatus() async {
    LocationPermission permission = await GeolocatorService.checkPermission();
    _setOutput('Location Permission Status: $permission');
  }

  Future<void> _requestPermission() async {
    LocationPermission permission = await GeolocatorService.requestPermission();
    _setOutput('Permission Requested: $permission');
  }

  Future<void> _openSettings() async {
    bool openSet = await GeolocatorService.openAppSettings();
    _setOutput('openSettings: $openSet');
  }

  Future<void> _checkLocationAccuracy() async {
    LocationAccuracyStatus accuracyStatus =
        await GeolocatorService.getLocationAccuracy();
    _setOutput('Location Accuracy: $accuracyStatus');
  }

  void _listenToServiceStatusStream() {
    _serviceStatusStreamSubscription =
        GeolocatorService.getServiceStatusStream().listen(
      (ServiceStatus status) {
        _setOutput('Service Status: $status');
        _logLocation('Service Status: $status');
      },
      onError: (e) {
        _setOutput('Error: $e');
      },
    );
  }

  Widget btn({
    required VoidCallback onPressed,
    required String child,
    Color? color,
  }) {
    return ElevatedButton(
      style: ElevatedButton.styleFrom(
        foregroundColor: color,
      ),
      onPressed: onPressed,
      child: Text(
        child,
        style: TextStyle(
          fontSize: 16,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }
}

위는 UI 코드, 밑은 로직 코드이다

// geolocator_service.dart
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';

class GeolocatorService {
  static Future<Position> getCurrentPosition(
      {LocationAccuracy accuracy = LocationAccuracy.high}) async {
    LocationPermission permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
      if (permission == LocationPermission.denied) {
        return Future.error('Location permissions are denied');
      }
    }

    if (permission == LocationPermission.deniedForever) {
      return Future.error('Location permissions are permanently denied');
    }

    return await Geolocator.getCurrentPosition(desiredAccuracy: accuracy);
  }

  static Future<Position?> getLastKnownPosition() async {
    return await Geolocator.getLastKnownPosition();
  }

  static Stream<Position> getPositionStream(
      {LocationAccuracy accuracy = LocationAccuracy.high,
      int distanceFilter = 100}) {
    final LocationSettings locationSettings =
        LocationSettingsConfig.getPlatformLocationSettings();
    return Geolocator.getPositionStream(locationSettings: locationSettings);
  }

  static Future<bool> isLocationServiceEnabled() async {
    return await Geolocator.isLocationServiceEnabled();
  }

  static Future<LocationPermission> checkPermission() async {
    return await Geolocator.checkPermission();
  }

  static Future<LocationPermission> requestPermission() async {
    return await Geolocator.requestPermission();
  }

  static Future<bool> openAppSettings() async {
    return await Geolocator.openAppSettings();
  }

  // (iOS 14+ and Android 만 적용 가능)
  static Future<LocationAccuracyStatus> getLocationAccuracy() async {
    return await Geolocator.getLocationAccuracy();
  }

  static Stream<ServiceStatus> getServiceStatusStream() {
    return Geolocator.getServiceStatusStream();
  }
}

class LocationSettingsConfig {
  static LocationSettings getPlatformLocationSettings() {
    if (defaultTargetPlatform == TargetPlatform.iOS ||
        defaultTargetPlatform == TargetPlatform.macOS) {
      return AppleSettings(
        accuracy: LocationAccuracy.high,
        activityType: ActivityType.fitness,
        distanceFilter: 100,
        pauseLocationUpdatesAutomatically: false,
        showBackgroundLocationIndicator: true,
      );
    } else if (defaultTargetPlatform == TargetPlatform.android) {
      return AndroidSettings(
        accuracy: LocationAccuracy.high,
        distanceFilter: 100,
        forceLocationManager: true,
        intervalDuration: const Duration(seconds: 10),
        foregroundNotificationConfig: const ForegroundNotificationConfig(
          notificationText: "App is tracking your location in the background.",
          notificationTitle: "Background Location Tracking",
          enableWakeLock: true,
        ),
      );
    } else {
      return LocationSettings(
        accuracy: LocationAccuracy.high,
        distanceFilter: 100,
      );
    }
  }
}

Geolocator는 플러터로 위치, 지도 기반 애플리케이션을 개발한다면 필수로 설정해야하는 패키지이지 않을까 싶다. 이런 유용하고 편한 패키지가 있음에 안도했다!

0
Subscribe to my newsletter

Read articles from Cherrie Kim directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Cherrie Kim
Cherrie Kim