본문 바로가기
Apple/UIKit

[iOS] 화면 방향 고정되어있을 때 Orientation 변화 감지하기

by 어멘드 2022. 7. 23.
반응형

 기기의 orientation 변화는 UIDevice.orientationDidChangeNotification을 받아 처리할 수 있다.
 그런데 만약 기기 자체 설정에서 "세로 화면 방향 고정"이 켜져있으면? 노티가 오지 않는다ㅠㅠ
 이렇게 orientation lock이 걸려있는데도 orientation을 써야할 때는 어떻게 해야 하는지 알아보았다.
 ** 틀린 내용이 있을 수도 있습니다.. 발견하시면 댓글로 알려주세요:) **

 

 나름대로 열심히 찾아봤는데 센서를 사용해 디바이스의 움직임을 감지한 뒤 orientation을 직접 계산하는 방법이 최선이었따...
 (혹시 더 좋은 방법을 알고계시다면 알려주세요🙏)
 디바이스의 움직임을 감지하기 위해서는 가속도계, 자이로스코프 등의 센서를 사용해야 하는데,
 이런 것들을 담당하는 프레임워크가 바로 Core Motion이다.
 일단 Core Motion 프레임워크부터 간단하게 살펴보자.


Core Motion

 가속도계 및 자이로스코프, 자기계, 기압계 등으로부터 얻을 수 있는 모션 및 환경 관련 데이터를 처리할 수 있게 해주는 프레임워크

 

 Core Motion을 통해
 1. 
센서가 측정한 raw value 자체를 얻어올 수도 있고,
 2. 이 raw value를 가공한 데이터를 얻어올 수도 있다.

 무슨 말이지 싶은데, 공식 문서의 예시를 보면 대충 이해가 간다.
 사용자가 기기를 손에 들고 흔들었다고 하자.
 이때 가속도계에 찍힌 raw value는 사용자가 손을 흔들어 만든 움직임에 의한 가속도 뿐만 아니라 중력 가속도도 반영되어 있다.
 Core Motion은 이 raw value를 알아서 잘 처리해서, (중력 가속도가 제외된) 사용자로 인해 발생한 가속도만 가공한 데이터도 제공한다.

 

 Core Motion의 모션 서비스를 사용하려면 일단 CMMotionManager 객체를 생성해야 한다.


CMMotionManager

 모션 서비스를 시작하고 관리하기 위한 객체

 

 CMMotionManager로 부터 네 가지 유형의 데이터를 받을 수 있다.
 내가 이해한 바로는 1, 2, 3번은 그냥 센서에서 읽어온 raw value고 4번이 가공된 데이터다.

  1. 가속도계 데이터: 3차원 공간에서 장치의 즉각적인 가속도
  2. 자이로스코프 데이터: 세 가지 기본 축 주변의 즉각적인 회전
  3. 자기계 데이터: 지구의 자기장에 대한 장치의 방향
  4. 디바이스 모션 데이터: 주요 모션과 연관된 속성들 (ex. 사용자에 의한 가속도, 회전률, 중력에 대한 장치의 방향 등)

 

 CMMotionManager를 사용할 때 주의할 점이 두 가지 있다.

  1. 사용 전 available 여부 체크하기
    : 모든 기기에서 모션 서비스를 제공하는 것은 아닌가보다. 각 서비스가 사용 가능한지 일단 체크하고 시작하라고 나와있다. available여부는 is(데이터유형)Available 프로퍼티로 선언되어 있다.(ex. 디바이스 모션 데이터는 isDeviceMotionAvailable)
  2. 인스턴스는 하나만 만들기
    : 여러 개의 인스턴스를 생성하면 가속도계와 자이로스코프에서 데이터를 수신하는 속도에 영향을 미칠 수 있다고 한다.

 

 저 네 가지 데이터 중에 대체 뭘 써야 하는지 난감했는데 문서를 뒤져보다 적절한 데이터를 발견했다.
 마지막 유형인 디바이스 모션에서 중력 벡터 정보가 담겨있는데,
 그럼 디바이스의 어느 부분이 아래를 향하고 있는지 알 수 있을테고,
 그럼 결국 orientation도 알아낼 수 있을 것이다👏👏
 디바이스 모션 데이터는 CMDeviceMotion 객체에 담겨있는데 이것부터 살펴보자.


CMDeviceMotion

 중력과 같은 편향을 제거한 가속도, 방향, 회전 및 자기장 데이터를 담고 있는 객체


 여러 센서에서 취합한 raw value를 Core Motion의 센서 융합 알고리즘에 의해 가공한 모션 관련 정보를 담고 있다.
 정보 목록은 아래와 같다.

  • 참조 프레임에 대한 3차원 공간에서의 장치 방향 = attitude
  • 편향이 제거된 회전률 = rotationRate
  • 현재 중력 벡터 = gravity
  • 사용자가 생성한 가속도 벡터(중력 제외) = userAcceleration
  • 현재 자기장 벡터 = magneticField

 

 CMMotionManager로부터 CMDeviceMotion 객체를 전달받으려면 아래 세 가지 작업을 해주어야 한다.

  1. isDeviceMotionAvailable 체크
  2. deviceMotionInterval 설정
  3. startMotionDeviceUpdate 호출

 

1. isDeviceMotionAvailable 체크

 CMMotionManager의 주의 사항 1번이었다.
 Device-Motion 서비스를 사용할 것이므로 isDeviceMotionAvailable == true 인지 체크해주고 시작하자.

2. deviceMotionInterval 설정

 업데이트를 시작하기 전, 먼저 업데이트 인터벌을 설정해주어야 한다.
 적절한 인터벌은 용도에 따라 달라질텐데, 최대 100Hz까지만 빈도 설정이 가능하다.

3. startMotionDeviceUpdate 호출

 이름 그대로 업데이트를 시작하는 메소드이다.
 아래와 같이 총 4가지가 있다.
 using: 움직임의 기준이 될 레퍼런스 프레임
 to: 업데이트를 실행할 오퍼레이션 큐 (main queue는 지양, 짧은 인터벌로 자주 업데이트 되기 때문에 부하 가능성)
 withHandler: 매 업데이트마다 실행되는 블록

func startDeviceMotionUpdates()

func startDeviceMotionUpdates(using referenceFrame: CMAttitudeReferenceFrame)

func startDeviceMotionUpdates(
    to queue: OperationQueue,
    withHandler handler: @escaping CMDeviceMotionHandler
)

func startDeviceMotionUpdates(
    using referenceFrame: CMAttitudeReferenceFrame,
    to queue: OperationQueue,
    withHandler handler: @escaping CMDeviceMotionHandler
)

 start가 있으니까 stop도 있다.
 더 이상 업데이트가 필요없는 경우 stopDeviceMotionUpdates()를 호출해서 꼭 종료시켜주어야 한다.

 

 이제 Device-Motion을 사용하는 방법은 알았고, DeviceMotion이 주는 정보들을 가지고 orientation만 판단하면 되는데,,

 처음에는 orientation 변화는 디바이스가 회전할 때 일어나니까, attitude나 rotationRate같은 회전 관련 정보를 써보려고 했다.
 근데 회전률을 가지고 orientation을 판단하는 코드를 어떻게 짜야할지 모르겠어서 포기하고...
 위에서 말한 것처럼 orientation에 따라 아래를 향하고 있는 디바이스의 부분이 다르기 때문에 gravity를 사용하는 코드를 짰다.

 gravity는 CMDeviceMotion에 get-only 프로퍼티로 선언되어 있다.
 중력가속도라 타입이 CMAcceleration이다.
 CMAcceleration 타입도 살펴보자.

var gravity: CMAcceleration { get }

CMAcceleration

 x, y, z 세 축에대한 가속도 값을 담고있는 구조체

 

 아래와 같이 세 축에 대한 가속도 값을 Double 타입 프로퍼티로 가지고 있다.
 중요한 것은 단위가 G라는 것! (G는 9.81 m/s^2)
 따라서 x, y, z의 범위는 [-9.81, 9.81]이 아니라 [-1, 1]이다.

var x: Double
var y: Double
var z: Double

 

 그리고 각 축은 아래와 같다.
 여기 나온 사진을 보는게 제일 이해가 잘된다.

 x축: 기기 왼쪽 면에서 오른쪽 면을 관통하는 축 (가로)
 y축: 기기 아랫면에서 윗면을 관통하는 축 (세로)
 z축: 기기 뒷면에서 화면을 관통하는 축

 

 이제 진짜 코드를 살펴보자.


Code

 일단 Core Motion 프레임워크를 사용하려면 임포트 해주어야 한다.

import CoreMotion

 

 그리고 모션 서비스를 시작하기 위해 CMMotionManager를 생성한다.
 이때 기억해야할 것은 CMMotionManager 객체는 한 개만 만들어야 한다는 것!
 그래서 CMMotionManager를 MotionDetector라는 클래스로 한번 래핑하고 싱글톤으로 만들어주었다.
 그리고 orientation 상태도 그냥 MotionDetector내에 담아두었다.

class MotionDetector {
    static let shared = MotionDetector()
    
    private let motion: CMMotionManager
    private(set) var orientation: UIDeviceOrientation
    
    private init() {
        self.motion = CMMotionManager()
        self.orientation = .unknown
    }
}

 

 Device-motion 감지를 시작하고 끝내는 메소드를 만들자.
 Interval은 1초에 10번 업데이트 되도록 1/10초로 설정해주었다.
 실제로 실행해보니 이정도면 orientation 감지하기에 충분한 인터벌같다.
 오히려 더 자주 업데이트하니까 A 상태에서 B 상태로 전환하는 과정에서 잠깐 스쳐가는 상태까지 다 노티가 와버린다.
 예를 들어 휴대폰을 뒤집어서 Face Up → Face Down상태로 갈 때 portrait, landscape, unknown,, 이렇게 다 노티가 온다.

func beginGeneratingOrientationDidChangeNotification() {
    guard self.motion.isDeviceMotionAvailable else { return }	// available 체크

    self.motion.deviceMotionUpdateInterval = (1.0/10.0)	// interval 설정
    self.motion.startDeviceMotionUpdates(to: OperationQueue()) { (data, error) in
        // gravity를 가지고 orientation을 계산하는 코드
    }
}

func stopGeneratingOrientationDidChangeNotification() {
    self.motion.stopDeviceMotionUpdates()
}

 

 이제 핸들러를 채워넣을 차례.

 Face Up과 Face Down의 경우 각각 휴대폰 뒷면과 앞면(화면)이 아래를 향하고 있고, (= z축)
 기기를 세로로 들고 있는 Portrait와 Portrait UpsideDown의 경우 각각 휴대폰의 아랫면과 윗면이, (= y축)
 기기를 가로로 들고 있는 Landscape Left와 Landscape Right의 경우 각각 휴대폰의 왼쪽 면과 오른쪽 면이 아래를 향한다. (= x축)

 그런데 x축과 y축은 직교하므로, 휴대폰을 가로로 들고있는 Landscape 상태를 아래와 같이 바꾸어 정의할 수 있다.
 휴대폰을 가로로 들고 있다. = 휴대폰의 세로(y축)가 수평에 가깝게 놓여있다.
 그리고 y축이 수평에 가깝다면 y축 방향으로 작용하는 중력가속도는 0에 가까울 것이다.


 이렇게 바꾸어서 정의한 이유는, 같은 Landscape일 때도 휴대폰을 들고 있는 각도가 다 다르기 때문이다.
 완벽하게 얼굴과 평행하게(=지면에 수직으로) 놓았을 때는 x축으로 방향으로 작용하는 중력가속도가 1.0 / -1.0이 되겠지만,
 보기 편한 각도로 휴대폰을 눕히면 중력가속도 크기가 줄어든다.
 하지만 각도가 달라도 y축은 계속 수평하게 유지된다!
 이런 로직을 바탕으로 짠 코드가 아래 코드이다.
 0.93랑 0.3은 시뮬레이션 해보면서 찾은 적당한 값이다.

if abs(gravityData.z) > 0.93 {		// 기기의 화면이 지면과 평행하게 놓여있다면
    self.orientation = gravityData.z < 0 ? .faceUp : .faceDown
} else if abs(gravityData.x) < 0.3 {	// 기기의 가로(x축)가 수평에 가깝게 놓여있다면
    self.orientation = gravityData.y < 0 ? .portrait : .portraitUpsideDown
} else if abs(gravityData.y) < 0.3 {	// 기기의 세로(y축)가 수평에 가깝게 놓여있다면
    self.orientation = gravityData.x < 0 ? .landscapeLeft : .landscapeRight
} else {
    self.orientation = .unknown
}

1. Face Up/Down

 Face Up/Down 상태에서는 화면이 지면과 평행하게 놓여있게 되므로,
 z축 방향으로 1.0 또는 -1.0에 가까운 중력가속도가 작용할 것이다. abs(gravity.z) > 0.93
 
그리고 뒷면 → 앞면 방향이 z축의 +방향이므로 gravity.z가 음수일 때, Face Up, 양수일 때 Face Down.

2. Portrait/UpsideDown

 위에서 설명한 것처럼, 휴대폰을 세로로 들고 있으면 휴대폰의 가로는 거의 수평이 되므로,
 x축 방향으로는 0에 가까운 중력가속도가 작용할 것이다. abs(gravity.x) < 0.3
 
그리고 아래 → 위 방향이 y축의 +방향이므로, gravity.y가 음수일 때 Portrait, 양수일 때 PortraitUpsideDown.

3. Landscape Left/Right

 휴대폰을 가로로 들고 있으면 휴대폰의 세로는 거의 수평이 되므로,
 y축 방향으로는 0에 가까운 중력가속도가 작용할 것이다. abs(gravity.y) < 0.3
 
그리고 좌 → 우 방향이 x축의 +방향이므로, gravity.x가 음수일 때 landscapeLeft, 양수일 때 landscapeRight

 

 마지막으로 orientation이 변경되었을 때 노티만 보내면 된다.
 먼저 MotionDetector의 타입 프로퍼티로 Notificaiton.Name을 정의하고,

static let orientationDidChangeNotification = Notification.Name("deviceOrientationDidChangeNotification")

 

 orientation 프로퍼티에 didSet으로 구현해주었다.

private(set) var orientation: UIDeviceOrientation {
    didSet {
        guard oldValue != orientation else { return }
        NotificationCenter.default.post(name: MotionDetector.orientationDidChangeNotification, object: self)
    }
}

 

MotionDetector 전체 코드

import CoreMotion
import UIKit

class MotionDetector {
    static let shared = MotionDetector()
    static let orientationDidChangeNotification = Notification.Name("deviceOrientationDidChangeNotification")
    
    private let motion: CMMotionManager
    private(set) var orientation: UIDeviceOrientation {
        didSet {
            guard oldValue != orientation else { return }
            NotificationCenter.default.post(name: MotionDetector.orientationDidChangeNotification, object: self)
        }
    }
    
    private init() {
        self.motion = CMMotionManager()
        self.orientation = .unknown
    }
    
    func beginGeneratingOrientationDidChangeNotification() {
        guard self.motion.isDeviceMotionAvailable else { return }
        
        self.motion.deviceMotionUpdateInterval = (1.0/10.0)
        self.motion.startDeviceMotionUpdates(to: OperationQueue()) { (data, error) in
            guard error == nil,
                  let gravityData = data?.gravity else { return }
            
            if abs(gravityData.z) > 0.93 {
                self.orientation = gravityData.z < 0 ? .faceUp : .faceDown
            } else if abs(gravityData.x) < 0.3 {
                self.orientation = gravityData.y < 0 ? .portrait : .portraitUpsideDown
            } else if abs(gravityData.y) < 0.3 {
                self.orientation = gravityData.x < 0 ? .landscapeLeft : .landscapeRight
            } else {
                self.orientation = .unknown
            }
        }
    }
    
    func stopGeneratingOrientationDidChangeNotification() {
        self.motion.stopDeviceMotionUpdates()
    }
}

Info.plist 설정

 Core Motion 공식 문서에는 아래와 같은 중요 사항이,
 "iOS 10.0 또는 이후에 연결된 iOS 앱은 필요한 데이터 유형에 대한 Info.plist 파일에 사용 설명 키를 포함해야 합니다. 이러한 키를 포함하지 않으면 앱이 충돌합니다. 특히 모션 및 피트니스 데이터에 액세스하려면 NSMotionUsageDescription이 포함되어야 합니다."

 Getting Processed Device-Motion Data 공식 문서에는 아래와 같은 중요 사항이 적혀있다.
 "앱이 가속도계와 자이로스코프 하드웨어의 존재에 의존하는 경우, Info.plist 파일의 UIRequiredDeviceCapabilities 키를 가속도계와 자이로스코프 값으로 구성하십시오. 이 키의 의미에 대한 자세한 내용은 정보 속성 목록 키 참조를 참조하십시오."

 

 위와 같은 내용이 적혀있어서 Info.plist 설정을 해주었는데, 권한 요청 얼럿이 안뜬다.
 이건 왜 그런지 아직도 알아내지 못했다..


> 레퍼런스

https://developer.apple.com/documentation/coremotion/getting_raw_accelerometer_events

https://developer.apple.com/documentation/coremotion/getting_processed_device-motion_data

https://developer.apple.com/documentation/coremotion

https://developer.apple.com/documentation/coremotion/cmmotionmanager

반응형

댓글