커스텀 알럿 만들기

hyun·2025년 8월 28일
0

iOS

목록 보기
46/54

UIKit 기반으로 앱 전반에서 재사용 가능한 커스텀 알럿 컨트롤러를 설계/구현
시스템 UIAlertController 대신 디자인 가이드를 반영하고 전환 효과/햅틱/접근성까지 고려한 컴포넌트를 만듦

목표

•	디자인 일관성: 앱의 타이포/컬러/라운딩을 그대로 적용.
•	간단한 API: presenter에서 한 줄로 띄우고, onConfirm 클로저로 후처리.
•	애니메이션: dim 페이드 + 카드 스프링 인, 디스미스도 부드럽게.
•	접근성/테스트: VoiceOver 읽기, UI 테스트 식별자.
•	확장성: 버튼 수/아이콘/체크박스/텍스트필드 등 확장 가능.

최종 API

PodoAlertController.presentDeleteTimerAlert(
  from: self,
  title: "이 타이머를 삭제할까요?",
  message: "삭제한 타이머는 복구할 수 없어요.",
  cancelTitle: "취소",
  confirmTitle: "삭제하기"
) {
  // 삭제 실행
}

modalPresentationStyle = .overFullScreen, modalTransitionStyle = .crossDissolve
배경이 투명하고 dimView를 직접 그릴 수 있도록 전체 화면 위에 덮는 구성

내부 구조 설계

뷰 계층

view
 ├─ dimView (UIControl)  // 배경 딤 + 탭으로 닫힘
 └─ containerView        // 카드 컨테이너 (cornerRadius 16, continuous)
     └─ contentStack (VStack, insets: 22/16/20/16)
         ├─ titleLabel
         ├─ messageLabel (multiline, center)
         └─ buttonStack (HStack, spacing 8, fillEqually)
             ├─ cancelButton
             └─ confirmButton

스타일링 포인트

•	Typography 시스템을 그대로 사용:
•	titleLabel → .headingLg
•	messageLabel → .bodyLg(.regular)
•	버튼 타이틀 → .labelLg(.semibold)
•	팔레트 적용:
•	cancel: 배경 .gray100, 타이틀 .gray900
•	confirm: 배경 .error, 타이틀 .appWhite
•	라운딩: container 16, 버튼 12 (continuous curve)

자동 레이아웃

•	center 스타일: center.equalToSuperview() + 좌우 inset 20
•	bottom 스타일: leading/trailing inset 12 + safeArea.bottom = 8
•	contentStack는 내부에서 마진 관리 + width ≤ 500로 아이패드/가로모드 대응

상호작용 & 애니메이션

바인딩

•	바깥 탭(딤) → 닫힘
•	취소 버튼 → 닫힘
•	확인 버튼 → 가벼운 햅틱 + 닫힘 후 confirmHandler() 호출

등장/퇴장 효과

•	animateIn
•	dim: alpha 0 → 1 (0.2s)
•	container: y: +20, alpha 0에서 시작 → 스프링으로 자연스럽게 (0.28s, damping 0.9, velocity 0.6)
•	animateOut
•	dim: alpha 1 → 0 (0.18s)
•	container: alpha 0, y: +10로 살짝 밀면서 페이드

시스템 알럿 대비 더 가벼운 느낌을 주고 햅틱으로 확신을 제공

접근성 & 테스트성

•	VoiceOver 기본 흐름
•	컨트롤러 표시 시 포커스를 titleLabel로 이동시키는 개선余地(아래 TODO).
•	UI 테스트 식별자
•	cancelButton → "podoAlert.cancel"
•	confirmButton → "podoAlert.confirm"
•	Dynamic Type 준비
•	UILabel/UIButton에 attributedText를 쓰는 경우 **adjustsFontForContentSizeCategory = true**와 폰트 스케일러 적용이 필요(아래 개선안).

연결

func showDeleteAlert(for timer: TimerModel) {
  PodoAlertController.presentDeleteTimerAlert(from: self) { [weak self] in
    guard let self else { return }
    do {
      try repository.delete(id: timer.timerID)
      // UI 갱신
      self.reload()
    } catch {
      // 에러 핸들링 (토스트/얼럿 등)
    }
  }
}

트러블슈팅 & 배운 점

1.	overFullScreen 없이 투명 배경 안 나오는 문제
•	기본 프레젠테이션은 배경을 불투명하게 처리할 수 있음 → overFullScreen으로 해결.
2.	상위 컨트롤러에서 present 시 애니메이션 이중 적용
•	animated: false로 present 후 내부에서 custom 애니메이션.
3.	스택 내부 spacing/마진 꼬임
•	isLayoutMarginsRelativeArrangement = true + layoutMargins로 일원화하니 레이아웃 충돌이 줄어듦.
4.	바텀시트 키보드 이슈(향후 확장)
•	바텀시트 모드에 텍스트필드를 추가할 경우 키보드 높이만큼 bottom 보정 필요. 현재 구조는 제약 변경으로 대응 가능.

계기

기본 UIAlertController로 끝내려 했지만
디자인 요구사항을 반영하려면 제약이 커서 커스텀 알럿 컨트롤러로 전환

마주한 문제

  1. 투명 배경이 안 나옴
    기본 모달 프레젠테이션(pageSheet 등)에서 백그라운드가 불투명하게 처리됨.
  2. present 애니메이션이 이중 적용
    상위에서 present(animated: true) + 내부 UIView.animate가 겹쳐 보임.
  3. StackView 간격/마진 꼬임
    arrangedSubview 개별 inset/constraint와 스택 spacing이 뒤엉켜 레이아웃 튐.
  4. 바텀시트 + 키보드 충돌
    텍스트 입력 시 키보드가 시트 하단을 가림.

해결 과정

1) 투명 배경

프레젠테이션을 덮기 모드로 강제.

let vc = PodoAlertController(...)
vc.modalPresentationStyle = .overFullScreen   // <- 투명 배경/커스텀 딤 뷰 가능
vc.modalTransitionStyle = .crossDissolve
present(vc, animated: false)                  // 이중 애니 방지(아래 2번과 연결)

.overCurrentContext를 쓸 경우엔 presenting 쪽 definesPresentationContext = true 필요 iPad나 iOS13+ 기본 pageSheet는 의도치 않은 반투명 시트가 되기 쉬워서 .overFullScreen이 안전.

2) 애니메이션 이중 적용 방지

외부는 정적 표시, 내부에서만 트랜지션 제어.

// 외부
present(vc, animated: false)

// 내부 커스텀 인/아웃
func animateIn() {
  containerView.transform = CGAffineTransform(translationX: 0, y: 20)
  dimView.alpha = 0
  UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut]) {
    self.dimView.alpha = 1
    self.containerView.transform = .identity
  }
}

func animateOut(completion: @escaping () -> Void) {
  UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseIn]) {
    self.dimView.alpha = 0
    self.containerView.alpha = 0
    self.containerView.transform = CGAffineTransform(translationX: 0, y: 10)
  } completion: { _ in completion() }
}

3) StackView 간격/마진 일원화

스택뷰의 margin만 진리로 삼고, 자식 뷰 개별 inset은 제거

let vStack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, buttonsStack])
vStack.axis = .vertical
vStack.spacing = 12
vStack.alignment = .fill
vStack.distribution = .fill

vStack.isLayoutMarginsRelativeArrangement = true
vStack.layoutMargins = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20)

// 버튼 스택도 동일 원칙
let buttonsStack = UIStackView(arrangedSubviews: [cancelButton, confirmButton])
buttonsStack.axis = .vertical
buttonsStack.spacing = 8
buttonsStack.isLayoutMarginsRelativeArrangement = true
buttonsStack.layoutMargins = .zero

spacing과 margins의 역할을 분리하고 자식에 임의 constraint/inset을 섞지 않기

4) 바텀시트 키보드 대응

키보드 프레임 변경 시 bottom 제약을 실시간 업데이트

private var bottomC: Constraint!

containerView.snp.makeConstraints {
  $0.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(20)
  bottomC = $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(20).constraint
}

override func viewDidLoad() {
  super.viewDidLoad()
  NotificationCenter.default.addObserver(self,
    selector: #selector(keyboardChanged(_:)),
    name: UIResponder.keyboardWillChangeFrameNotification,
    object: nil)
}

@objc private func keyboardChanged(_ note: Notification) {
  guard
    let info = note.userInfo,
    let end = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
    let dur = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
    let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt
  else { return }

  let kb = view.convert(end, from: nil)
  let overlap = max(view.bounds.maxY - kb.origin.y, 0)
  bottomC.update(inset: 20 + overlap) // 기본 20 + 키보드 높이

  UIView.animate(withDuration: dur,
                 delay: 0,
                 options: UIView.AnimationOptions(rawValue: curveRaw << 16)) {
    self.view.layoutIfNeeded()
  }
}

additionalSafeAreaInsets.bottom = overlap도 가능. 스크롤 콘텐츠면 contentInset 업데이트도 선택지

= 디자인 요구사항을 충족하는 커스텀 알럿 완성(?)
투명 딤/카드 애니메이션/간결한 레이아웃/키보드 확장성까지 기반 마련함

•	.overFullScreen이 커스텀 알럿의 전제조건이다
•	애니메이션의 내부 제어가 가장 깔끔하고 예측 가능하다.
•	스택뷰는 layoutMargins 기반 일원화가 레이아웃 충돌을 줄인다.
•	바텀시트는 초기에 키보드 플랜(노티→제약 업데이트)을 설계해두면 확장 비용이 낮다.

0개의 댓글