타다 팀의 iPhone Duo 대응기
들어가며
안녕하세요. 타다 iOS Developer 이재현입니다.
지난 10월, Apple이 첫 폴더블 디바이스인 iPhone Duo를 공개했는데요. Duo는 접었을 때는 외부 스크린을, 펼쳤을 때는 내부 스크린을 사용하고, 반접힘(partially folded)이라는 새로운 상태도 지원합니다. 사용자가 기기를 다루는 방식에 따라 화면의 크기와 형태가 달라지므로, 타다 앱 역시 하나의 iPhone 화면만을 전제로 한 개발 방식에서 벗어나야 했습니다.
타다는 프리미엄 택시 서비스로서 탑승부터 하차까지, 편안한 이동의 여정을 섬세하게 설계해 왔고 앱은 그 경험을 여는 첫 시작이라 생각합니다. 그래서 새로운 형태의 기기에서도 기존의 경험이 온전히 이어질 수 있도록 기기 출시 일정에 맞춰 Duo 대응을 진행하게 되었습니다.
이 글에서는 iPhone Duo를 대응하며 타다 팀이 어떤 원칙을 정의했는지, 그리고 해당 원칙을 지키기 위해 설계를 어떻게 바꾸게 되었는지를 이야기해 보려 합니다.
타다 앱의 화면 구성
본론에 앞서, 타다 앱의 화면 구성을 먼저 소개하겠습니다.
타다 앱은 크게 일반 화면과 지도 화면으로 나눌 수 있습니다.
우선, 일반 화면은 앱 경험의 다수를 이루는 일반적인 스크롤 뷰를 의미합니다. 타다 앱에서는 예약, 이용내역, 설정 등에서 활용하고 있습니다.
만약 타다를 탑승하신다면, 홈부터 라인업 선택, 배차 완료까지 탑승의 모든 여정에서는 지도 화면을 만나보실 수 있습니다. 지도 화면은 전체 백그라운드에 지도를 두고 그 위에 여러 요소를 배치하는데요.
지도 위에 올라오는 요소들은 역할에 따라 다음과 같이 나뉩니다.
- 헤더(header): 지도 화면 위 상단 영역
- 오버레이 카드(overlay card): 지도 화면 위 하단 요소
- 지도 버튼(map button): 예약하기, 내 위치 등 지도 위 버튼
- 핀(pin): 출발지처럼 지도 중앙에 고정되는 요소
이밖에도 지도에는 출도착지와 같은 마커들과 경로도 함께 그려집니다. 지도는 이러한 마커와 경로가 헤더나 오버레이 카드에 가려지지 않도록 포커싱하고, 해당 영역을 이하 focus area라고 부르겠습니다.
iPhone Duo 개념
이어서 iPhone Duo에 대응하며 알아 두어야 했던 개념 세 가지를 짚고 넘어가겠습니다.
reserved region (예약 영역)
iPhone Duo가 반접힘 상태에 놓이면, 중앙의 힌지 영역에 놓인 콘텐츠는 가독성과 조작성이 모두 저하됩니다. Apple은 해당 부분을 reserved region(예약 영역)으로 정의하고 있으며, HIG(Human Interface Guideline)에서는 버튼처럼 상호작용하는 요소가 이 영역에 걸치지 않도록 안내하고 있습니다. 즉, 콘텐츠는 reserved region을 가로지르지 않고 어느 한쪽에 놓여야 합니다.
ArrangementView
ArrangementView는 주 화면(primary)과 보조 화면(secondary), 두 개의 뷰를 받아 기기의 상태에 맞게 배치해 주는 컨테이너입니다. 두 뷰를 나란히 두는 split 스타일과 겹쳐서 배치하는 overlay 스타일을 제공합니다.
size class
size class는 iOS가 현재 화면의 가로와 세로 공간을 판단해 반환하는 값으로, 각각 compact 또는 regular 중 하나입니다.
Apple은 화면의 상태마다 별도의 레이아웃을 만들기보다, 외부 스크린은 compact, 내부 스크린은 regular로 보고 이 두 값에 맞춰 설계하도록 권장합니다. 고정된 너비나 특정 화면 크기에 맞춘 수치를 피하고 앱이 어떤 크기로든 자유롭게 조절되도록 만들면, Duo 대응의 대부분이 자연스럽게 해결된다는 것입니다.
타다 역시 이 가이드라인을 따르기로 했고, 가장 먼저 regular 환경에서 지도와 카드를 어디에 배치할지부터 정해야 했습니다.
지도와 오버레이 카드의 자리 정하기
Duo 대응을 위해서는 일부 화면만이 아닌, 앱 전체의 레이아웃에 대한 재정의가 필요했습니다. 이에 디자인팀과 공통 레이아웃 원칙을 먼저 수립하고, 이를 바탕으로 각 화면을 순차적으로 개선해 나가기로 했습니다.
먼저 논의한 부분은 지도 화면의 배치였는데요. 앞서 소개한 것처럼, 타다 서비스의 핵심 경험인 탑승은 대부분 지도 화면 위에서 이루어지기 때문입니다. 디자인팀의 깊은 고민 끝에 Duo 화면을 펼쳤을 때는 지도를 왼쪽에, 오버레이 카드를 오른쪽에 두는 것을 레이아웃 원칙으로 삼았습니다.
여러 지도 앱이 정보 패널을 왼쪽에, 지도를 오른쪽에 배치하고 있어 의아하게 느끼시는 분들도 있을 것 같습니다. 하지만 지도가 핵심인 여타 지도 서비스들과 달리, 타다에서는 출도착지를 확인하고 차량 라인업을 고르는 것처럼 사용자가 "결정"을 내리는 대부분의 행동이 카드 형태의 오버레이에서 이루어집니다. 여기에 지도는 그 결정을 돕는 보조 정보에 가깝습니다. 그래서 정보 위계(information hierarchy)에 맞춰 핵심 정보인 오버레이 카드를 오른쪽에, 부수 정보인 지도를 왼쪽에 배치했습니다.
이는 기기를 접고 펼 때도 이점이 있습니다. 내부 스크린에서도 주 활동 영역이 같은 오른쪽에 놓이면, 기기의 상태가 바뀌더라도 사용자의 시선과 손이 머무는 자리가 유지되기 때문입니다.
이와 함께, 일반 iPhone에서는 기존의 레이아웃이 최대한 유지되어야 한다는 원칙도 정했습니다. Duo 사용자는 전체 iPhone 기기 사용자의 일부이고, Duo 대응과 상관 없이 모든 유저에게 일관적인 사용자 경험을 전달하고 싶었기 때문입니다.
다만 이 원칙을 구현에 반영하기 위해서는 어려움이 존재했습니다. 기기의 상태에 따라 오버레이 카드의 위치와 focus area가 함께 달라지기 때문입니다. 이에 화면마다 개별적으로 처리하는 대신, 디자인 원칙을 코드에서도 하나의 공통 규칙으로 다룰 수 있는 방법을 고민하게 되었습니다.
지도의 focus area는 누가 계산해야 할까요?
앞서 정한 레이아웃 원칙을 바탕으로 라인업 선택 화면을 구현한다고 가정해 보겠습니다. 사진 9와 같이 일반 iPhone과 Duo를 접은 상태에서는 오버레이 카드의 위쪽을, Duo를 펼친 상태에서는 카드의 왼쪽을 focus area로 사용해야 합니다.
as-is: 화면마다 focus area를 정의
그렇다면 기존의 지도 화면은 focus area를 어떻게 정하고 있었을까요?
타다 iOS 앱은 화면이나 기능 단위의 모듈인 RIB이 트리 형태로 연결된 SwiftUIRIBs 아키텍처로 구성되어 있습니다.
지도를 쓰는 화면들은 각각 별도의 RIB이지만, 하나의 지도 인스턴스를 함께 사용합니다. 때문에 각 화면은 지도를 사용할 때마다, 자신이 지도를 얼마나 가리는지 지도에게 직접 알려야 했습니다. 이를 위해 기존에는 각 RIB의 Builder가 지도 padding을 담을 객체(MapPaddingSource)를 만들어 View에 주입하고, View는 화면 아래 카드의 높이를 직접 측정해 그 값을 지도의 아래쪽 padding으로 지정하고 있었습니다.
// Builder
let view = RidePointPickerView(
mapPaddingProvider: self.mapController.paddingProvider,
mapPadding: MapPaddingSource()
)
// View
.onAppear { mapPaddingProvider.add(paddingSource: mapPadding) }
.onDisappear { mapPaddingProvider.remove(paddingSource: mapPadding) }
.onChange(of: containerViewHeight) { _, height in
mapPadding.edgeInsets = UIEdgeInsets(
top: UIApplication.shared.safeAreaTopHeight,
left: 0,
bottom: height,
right: 0
)
}화면마다 MapPaddingSource로 padding을 알리면, MapPaddingProvider가 이를 지도에 반영하는 구조예요.
일반 iPhone에서 오버레이 카드는 늘 화면의 아래쪽에 위치했기 때문에 그동안은 focus area를 직접 지정하는 방식만으로도 충분했었습니다.
하지만 Duo를 펼치면 오버레이 카드가 화면 오른쪽으로 이동하고, 이에 따라 계산해야 하는 padding 위치가 달라집니다. 또한, 반접힘 상태에서는 reserved region까지 고려해야 합니다. 기존 방식대로라면 모든 지도 화면이 기기의 상태마다 padding을 따로 계산해야 했고, 같은 로직이 화면 수만큼 반복될 수밖에 없었습니다.
to-be : focus area를 한 곳에서 계산
문제를 해결하기 위해, focus area를 각 화면이 아닌 한 곳에서 계산하도록 구조를 바꾸었습니다. 각 화면은 자신의 요소를 정해진 자리에 배치하기만 하고, focus area의 계산은 공통 뷰가 맡도록 하는 것입니다.
구현에 앞서 먼저 기존 지도 화면들의 레이아웃을 모두 살펴보고, 공통으로 반복되는 요소를 역할에 따라 나누었습니다.
public protocol TadaMapView: TadaView {
var mapView: RiderMapViewable { get }
@ViewBuilder var mapHeader: MapHeader { get }
@ViewBuilder var mapContent: MapContent { get }
@ViewBuilder var mapFooter: MapFooter { get }
@ViewBuilder var mapPins: MapPins { get }
}그리고 지도를 사용하는 화면들은 TadaMapView 프로토콜을 따르도록 하여, 각 화면이 자신의 컴포넌트를 알맞은 영역에 배치하도록 구현했습니다. 이렇게 전달한 컴포넌트를 지도의 알맞은 위치에 놓는 일은 TadaMapScreen이라는 공통 뷰가 담당합니다.
struct TadaMapScreen<Screen: TadaMapView>: View {
@State private var paddingSource = MapPaddingSource()
private func updatePadding() {
self.paddingSource.edgeInsets = MapCameraArea.insets(
layout: self.cameraLayout,
coveredFrames: self.coveredFrames
)
}
}padding을 담는 MapPaddingSource는 이제 Builder가 아니라 TadaMapScreen이 직접 가지고 있습니다. 따라서 Builder는 더 이상 지도 padding에 관여하지 않습니다.
focus area의 두 가지 계산 방식
TadaMapScreen이 focus area를 계산하는 방식은, 오버레이 카드가 지도를 어떻게 가리는지에 따라 두 가지로 나뉩니다.
1. focus area와 오버레이 카드가 vertical 정렬
우선, 일반 iPhone처럼 카드가 지도 아래쪽에 놓이는 배치에서는 카드와 시트에 가려지는 영역을 제외하고 focus area를 계산합니다.
카드와 시트가 지도를 가리는 영역은 콘텐츠와 화면 크기에 따라 달라지는 값이므로, 고정된 수치가 아닌 실제 레이아웃 결과를 기준으로 계산해야 합니다. 그래서 각 자리에 들어간 뷰가 자기 영역을 Anchor<CGRect>에 담아 preference로 올려 보내면, TadaMapScreen이 이 값들을 모아서 focus area를 계산하도록 했습니다.
func mapCameraInset(_ edge: MapCameraInsetEdge, isActive: Bool = true) -> some View {
self.transformAnchorPreference(key: MapCameraInsetKey.self, value: .bounds) { value, anchor in
guard isActive else { return }
value.insets.append(MapCameraInset(edge: edge, bounds: anchor, ...))
}
}영역을 전달할 때는 frame 대신 anchor를 사용했습니다. frame은 값을 측정하는 시점에 기준 좌표계를 함께 지정해야 하지만, anchor는 뷰의 영역에 대한 참조만 전달하고 좌표 변환은 값을 받는 쪽에서 수행합니다.
// TadaMapScreen
.backgroundPreferenceValue(MapCameraInsetKey.self) { value in
GeometryReader { proxy in
let frames = value.insets.map {
CoveredFrame(edge: $0.edge, frame: proxy[$0.bounds])
}
Color.clear
.onChange(of: frames, initial: true) { _, frames in
self.coveredFrames = frames
self.updatePadding()
}
}
}TadaMapScreen은 frames의 변경이 감지될 때마다 padding을 업데이트해요.
이렇게 하면 각 구성 요소의 역할이 명확히 나뉩니다. 헤더나 시트는 지도 화면의 구조를 알 필요 없이 자신이 점유한 영역만 전달하고, TadaMapScreen은 전달받은 영역을 자신의 좌표계로 변환해 focus area를 계산합니다.
덕분에 새로운 컴포넌트가 추가되더라도 정해진 영역에 배치하기만 하면, focus area의 계산 로직은 수정할 필요가 없습니다.
2. focus area와 오버레이 카드가 horizontal 정렬
카드가 지도 옆에 놓이는 배치에서는 별도의 측정 없이 arrangement가 정한 영역을 사용합니다.
지도 화면을 background로 두고, 그 위에 overlay ArrangementView를 올려 secondary를 focus area로 정의하고 있습니다.
TadaOverlayArrangementView {
self.cards
} secondary: {
Color.clear
.onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) {
self.cameraArea = $0
}
}TadaOverlayArrangementView는 ArrangementView의 wrapper예요.
+) UIScreen 접근 제거하기
이와 함께, 컴포넌트의 크기를 UIScreen 기준으로 계산하던 코드들도 실제 뷰의 크기를 바라보도록 변경했습니다.
예시로 타다 앱은 focus area 내에 경로가 존재하는 경우, focus area의 edge와 경로 간의 여백을 두고 있는데요. 기존에는 이 비율을 UIScreen의 크기 대비로 계산하고 있었습니다. 하지만 UIScreen은 앱이 그려지는 window가 아닌 디스플레이 전체의 크기를 반환하기 때문에, window의 크기가 자유롭게 조절되는 환경에서는 실제 뷰의 크기를 정확히 반영하지 못합니다.
// 기존
fittingAreaInset: .init(
top: UIScreen.heightExceptSafeArea * 0.03,
left: UIScreen.widthExceptSafeArea * 0.1,
bottom: UIScreen.heightExceptSafeArea * 0.03,
right: UIScreen.widthExceptSafeArea * 0.1
)
// 변경
fittingArea: .ratio(
.init(top: 0.03, left: 0.1, bottom: 0.03, right: 0.1)
)그래서 TadaMapScreen에 여백의 비율 값만 전달하고, 실제 fitting area는 focus area를 기준으로 계산하도록 분리했습니다. 덕분에 focus area의 크기와 형태가 달라지더라도, fitting area와 focus area 간의 여백 비율은 동일하게 유지되게 됩니다.
바텀시트의 위치는 어떻게 정해질까요?
Apple은 iPhone Duo 대응 시 시스템 컴포넌트의 사용을 권장하고 있습니다. 시스템 시트는 reserved region을 자동으로 회피하도록 설계되어 있기 때문입니다. 타다 역시 탭바와 vertical edge의 버튼은 시스템 컴포넌트를 사용하고 있으나, 바텀시트는 기존의 커스텀 구현을 유지하기로 했습니다.
커스텀 바텀시트를 유지한 데에는 두 가지 이유가 있습니다.
- 첫째, 시스템 시트는 별도의 view controller로 표시되기 때문에, RootView의 overlay에 그려지는 타다의 알럿과 로딩이 시트 아래에 가려집니다.
- 둘째, 디자인팀이 의도한 바텀시트의 형태와 동작을 동일하게 구현하기 위해 커스텀 구현을 선택했습니다.
부모 화면의 레이아웃을 알 수 없는 바텀시트
타다 디자인팀은 레이아웃 원칙을 정하면서, 지도 화면에서 띄운 바텀시트는 오버레이 카드 위에 정렬되어 올라오도록 정의했습니다. 카드와 시트가 같은 열에 위치하면, 시트가 열리더라도 사용자의 시선과 조작 위치가 바뀌지 않기 때문입니다.
반면 일반 화면에서 띄운 시트는 화면 중앙에 올라와야 합니다. 같은 바텀시트라도 어느 화면 위에 떠 있는지에 따라 위치가 달라지는 것입니다.
이러한 바텀시트는 여러 화면에서 재사용되는 독립 컴포넌트입니다. 각 바텀시트는 부모 화면의 View 내부에 존재하는 시트 컨테이너에 배치될 뿐, 해당 화면에 대한 정보는 전달받지 않습니다. 즉, 바텀시트는 상위 화면이 오버레이 화면인지 일반 화면인지 판단할 수 없습니다.
해당 레이아웃 정보를 전달하는 가장 단순한 방법은 바텀시트를 표시하는 화면이 자신의 레이아웃 값을 시트에 직접 주입하는 것입니다. 하지만 이 방식은 바텀시트를 표시하는 모든 화면에 동일한 로직을 요구하고, 일부 화면에서 이를 누락할 경우 시트가 의도와 다른 위치에 표시될 수 있습니다.
또한 토스트의 경우에는 어느 화면에서 띄우든 앱의 최상위 뷰에서 그려지므로, 현재 표시 중인 화면의 레이아웃을 알 수 없습니다. 그렇기에 직접 주입하지 않고도 각 화면의 레이아웃을 앱 전체에 전달할 수 있는 방법이 필요했습니다.
예를 들어 사용자가 타다를 호출한 뒤 타다 앱의 RIB 구조는 다음과 같습니다.
타다를 호출하면 Ride 아래에 배차 대기(Pending), 배차 완료(Matched) 같은 화면이 상태에 따라 붙습니다. 이 중 Matched는 오버레이 화면이므로, Root에 그려지는 토스트와 전역 바텀시트도 오버레이 배치를 따라야 합니다.
Root에서 전파하는 화면 레이아웃
해당 문제를 해결하기 위해, 각 화면이 선언한 레이아웃을 Root까지 올려 보내고 Root가 이를 다시 앱 전체로 내려 주는 구조를 만들었습니다. 먼저 모든 화면이 채택하는 TadaView 프로토콜에 screenLayout을 추가하고, 각 화면은 자신이 어떤 종류의 화면인지만 선언하도록 했습니다.
각 화면이 선언하는 레이아웃은 다음 세 가지입니다.
screenLayout | 적용 화면 | 배치 |
|---|---|---|
.column | 일반적인 본문 화면 (기본값) | 본문을 최대 520pt 너비로 중앙에 정렬 |
.fullScreen | intro, splash 등 화면 전체를 사용하는 화면 | 화면 전체 사용 |
.overlay | 지도 위에 카드를 띄우는 화면 | 지도는 background에 표시하고, 시트와 토스트는 같은 열에 정렬 |
이렇게 선언한 값은 먼저 preference를 타고 앱 루트까지 올라갑니다. 루트 뷰는 현재 화면의 레이아웃을 받아 \.screenLayout environment로 앱 전체에 전파하고, 화면 안의 시트와 떠 있는 버튼뿐 아니라 루트에 그려지는 전역 토스트와 바텀시트까지 모두 이 값을 읽습니다.
이로써 바텀시트는 자신이 어느 화면 위에 놓였는지 알지 못하더라도, View 계층을 따라 전달된 레이아웃 값만으로 위치를 정할 수 있게 되었습니다. 따라서 Matched의 overlay는 부모로 전달되고, Root는 이를 받아 토스트와 전역 바텀시트를 오버레이 배치에 맞춰 띄울 수 있습니다.
iOS 27 분기 관리하기
ArrangementView와 toolbarVerticalEdge는 iOS 27에서 추가된 API입니다. 앱은 iOS 27 미만 버전들을 지원해야 하기에, 해당 API를 사용하는 곳마다 #available 분기가 필요합니다. 이러한 분기가 화면 곳곳에 흩어지면, 새로운 화면을 만들 때마다 Duo 대응 여부를 개별적으로 고려해야 합니다.
그래서 새 API는 공통 UI 모듈의 Adaptive 폴더 안에서만 사용하고, 기능 코드에는 wrapper만 제공하기로 했습니다. wrapper는 iOS 27 미만에서 예전과 같은 결과를 내도록 만들었는데, 앞서 focus area를 계산하는 부분에서 살펴본 TadaOverlayArrangementView가 대표적인 예입니다.
public var body: some View {
if #available(iOS 27.1, *) {
ArrangementView {
self.primary
} secondary: {
self.secondary
}
} else {
ZStack(alignment: .center) {
self.secondary
self.primary
}
}
}툴바 또한 같은 방식을 사용했습니다. iPhone Duo에서 툴바가 화면 측면에 세로로 배치되면, 뒤로 가기와 같은 버튼은 화면 내부가 아닌 시스템 툴바에 위치해야 합니다.
이 또한 화면마다 버튼을 별도로 구현하는 대신, 기존 버튼에 .adaptiveStyle(placement:)을 적용하면 상황에 맞는 위치에 표시되도록 했습니다.
// 듀오 대응 전
TadaToolbar {
BackButton(action: {
back()
})
// ...
// title
}
// 듀오 대응 후
TadaToolbar {
BackButton(action: {
back()
})
// 한줄 추가
.adaptiveStyle(.back, placement: .cancellationAction)
// ...
// title
}맺으며
화면 단위로 크기에 대응하던 방식에서 벗어나 레이아웃을 규칙 기반으로 정리하고 나니, iPhone Duo를 위해 설계한 구조가 iPad 빌드에서도 자연스럽게 동작하게 되었습니다. window 크기가 바뀌더라도 각 화면은 선언한 규칙들을 따르므로, 기기 크기마다 별도로 대응할 필요가 없습니다.
덧붙여, 본 글에서 소개한 레이아웃과 구조는 변경의 여지를 열어두고 있습니다. 향후 iPhone Duo 사용자가 증가하고 실제 사용 데이터가 축적되면, 이를 근거로 UX/UI를 지속적으로 개선해 나가고자 합니다.
Duo 대응을 준비하고 계신 다른 팀에 조금이나마 도움이 되었으면 좋겠습니다. 읽어 주셔서 감사합니다.
참고
이 글이 도움이 되었나요?




























