애플사이다의 iOS 개발 일지

[iOS] Bottom Sheet 버그 - viewDidLoad에서 layout을 설정하면 안되는 이유 본문

iOS

[iOS] Bottom Sheet 버그 - viewDidLoad에서 layout을 설정하면 안되는 이유

Applecider 2024. 4. 14. 21:48

서비스에서 진행 중인 굵직한 이벤트가 있다면

앱을 실행하고 Home 화면에 진입했을 때, 애니메이션과 함께 Bottom Sheet (이하 바텀시트)를 띄워준다. 

 

바텀시트를 노출하는 앱은 매우 흔하다.

dim 영역이나 x 버튼을 탭하면 Sheet가 내려가도록 구현되어 있다.

 

29CM 무신사 ABLY 오늘의집 Shein 쿠팡

 

개인적으로 귀찮아서 습관적으로 무조건 닫기 버튼을 누르긴 하지만... ㅋㅋㅋ (솔직히 사용성 너무 안 좋다고 생각함)

그래서인지 아마존, Etsy, Temu 같은 글로벌 앱에서는 이러한 시트를 띄우지 않고 있었다.

 

아무튼 이번에는 이러한 바텀시트를 구현하는 과정에서 겪었던 버그를 파헤쳐보려 한다.

제목에서 벌써 스포하고 있듯이 ViewDidLoad 시점에 Bottom Sheet의 layout을 잡아줬던 게 원인이었다.

 


먼저, 버그는 이랬다

viewController 위에 바텀시트를 subview로 올리고 

바텀시트의 imageView의 width/heightviewController width와 동일하게 잡아줬다.

 

그리고 아래에서 위로 올라오는 애니메이션 효과를 주기 위해 

초기에는 imageView의 top을 바텀시트의 bottom으로 맞춰서 바닥에 숨겨두고,
바텀시트를 띄울 때는 imageView의 top을 바텀시트의 height 만큼 올려줬다. 

 

1) HomeBottomSheetView 내부적으로 layout을 잡아주는 코드

// layout 초기 설정
addSubview(imageView)
imageView.snp.makeConstraints { make in
    make.leading.trailing.equalToSuperview()
    make.top.equalTo(snp.bottom)
    make.height.equalTo(snp.width) // ✅ 바텀시트 height == width
}

// 바텀시트를 show/close하는 로직
func show() {
    layoutIfNeeded()

    UIView.animate(withDuration: 0.25) {
        self.dimView.alpha = 1
        // ✅ imageView top == 바텀시트 height 만큼 올려주기
        self.setImageViewOffsetY(to: -self.imageView.bounds.size.height)
    }
}

func close() {
    UIView.animate(withDuration: 0.25) {
        self.dimView.alpha = 0
        // ✅ imageView top == 바텀시트 bottom으로 내려서 숨기기 
        self.setImageViewOffsetY(to: 0)
    } completion: { [weak self] _ in
        self?.removeFromSuperview()
    }
}

private func setImageViewOffsetY(to offsetY: CGFloat) {
    imageView.snp.updateConstraints { make in
        make.top.equalTo(snp.bottom).offset(offsetY)
    }
    layoutIfNeeded()
}

 

2) ViewController에서 바텀시트를 올려주는 코드

override func viewDidLoad() {
    super.viewDidLoad()

    showBottomSheet()
}

private func showBottomSheet() {
    let bottomSheetView = HomeBottomSheetView()

    // ✅ dim 영역이 있으므로 전체화면 크기로 잡아줌 
    view.addSubview(bottomSheetView)
    bottomSheetView.snp.makeConstraints { make in
        make.edges.equalToSuperview()
    }
    bottomSheetView.show()
}

 

그런데..! 아래와 같이 기기마다 바텀시트의 bottom inset이 다르게 나타났다.

(기기는 왼쪽부터 iPhone 15 / 13 mini / SE)

삼체 너무 재밌음.

 

버그 원인은 뭘까?

결론부터 말하자면 원인은

1) ViewController와 연결된 storyboard가 존재했고

2) 바텀시트의 layout을 viewDidLoad 시점에 설정했기 때문이었다.

 

conflict을 피하기 위해 storyboard 없이 layout을 그리는 데에 익숙해지다보니

이처럼 storyboard를 사용하고 있는 ViewController의 특성을 고려하지 못했다...! 

 

🧪 ViewController의 lifecycle method로 실험해 보자

view를 띄울 때

viewDidLoad, viewWillAppear, viewDidLayoutSubviews가 차례로 호출되는데

각 시점에서 view의 width를 찍어봤다.

override func viewDidLoad() {
    super.viewDidLoad()
    print("viewDidLoad ...", view.frame.size.width)
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    print("viewWillAppear ...", view.frame.size.width)
}

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    print("viewDidLayoutSubviews ...", view.frame.size.width)
}

 

그랬더니...!

실험했던 iPhone SE의 Screen width는 375인데

아래처럼 viewDidLoad에서만 393으로 잘못된 값이 찍히는 것을 확인할 수 있었다. 

 

그럼 393은 어디에서 온 숫자일까?

스오플을 뒤져보니... 놀랍게도...!
storyboard에서 설정한 기기의 Screen width였다.

세상의 모든 Storyboard 없애고 싶다..


ViewDidLoad에서 Layout을 잡으면 안 되는 이유는?

그렇다면 왜 viewDidLoad에서는 잘못된 너비가 찍히는 걸까?

이 시점에는 view frame이 정확히 계산되지 않은 상태이기 때문이다.

 

공식 문서에서 확인해보자.

viewDidLoad() 를 다시 보게 될 날이 올 줄이야..

 

 

공식문서를 읽어보면...

"viewDidLoad가 호출되는 시점은 view hierarchy를 메모리에 로드한 직후"라고 말한다.

(view를 view hierarchy에 추가했다는 뜻이 아니다.)

 

공식문서가 좀 불친절하긴 하지만

마지막 문장 "viewDidLoad를 override하여 nib 파일에서 로드한 view에 대한 추가적인 초기화를 할 수 있다"에서 볼 수 있듯이

view를 초기화까지만 하고, layout은 하면 안 되는 걸 유추해야 할 것 같다.

 

그럼 찾아본 김에 viewWillAppear(_:)도 살펴보자.

"view를 표시하는 것과 관련된 작업"을 할 때 viewWillAppear를 override하라고 명시되어 있다.

 

iOS13+부터 쓸 수 있는 viewIsAppearing(_:) 도 보자.

viewWillAppear가 호출된 이후

view가 실제로 hierarchy에 추가된 뒤, viewIsAppearing이 호출되는 것을 알 수 있다.

(view가 hierarchy에 추가됐다는 것은 parentView or window에 올라갔음을 의미한다.)

 

왠지 hierarchy에 추가된 이후 시점인 viewIsAppearing에서

바텀시트를 띄워주면 해결될 것 같지만... 그건 아니었다. 😂

 

참고 - 호출 순서

 

아래에 viewWillAppear에 대한 자세한 내용이 들어있었다.

구체적인 메커니즘은 다른 문서를 뒤져봐야 더 정확히 알겠지만..

일단 공식문서를 바탕으로 각 lifecycle 메서드에서 바텀시트가 어떻게 나타나는지 테스트해봤다.

 

위 내용을 보면

viewWillAppear 시점에 설정한 애니메이션은

viewController 전환 애니메이션동시에 화면에 나타난다는 것을 알 수 있다.

 

예를 들어,

홈 바텀시트가 올라오는 애니메이션을 viewWillAppear 시점에 설정했다면

이건 NavigationController에 의해 push 된 homeViewController가 오른쪽에서 밀려 들어오는 애니메이션

동시에 보여진다는 거다.

아래처럼 말이다.

vc은 오른쪽에서 왼쪽으로, 바텀시트는 아래에서 위로

 

홈 화면은 대부분 앱을 실행하자마자 보이는 첫 번째 화면이기 때문에

viewController 전환 애니메이션 없이, 바텀시트 애니메이션만 나타낼 수 있다.

(만약 홈 화면이 첫번째 화면이 아니라면 viewDidAppear 시점에 띄우는 게 차선책이다.)

 

공식문서 내용처럼

viewWillAppear 직후에 viewIsAppearing이 호출되는데

viewIsAppearing에서 바텀시트를 띄우면, 위로 올라오는 애니메이션이 보이지 않는다.

 

마지막으로, viewDidLayoutSubviews()는 어떨까?

"뷰의 subviews가 모두 layout 완료된 이후에 변화를 주고 싶을 때 override 한다"고 되어있다. 

이것만 봤을 때는 이 시점에 바텀시트를 띄워도 될 것 같다.

viewDidLayoutSubviews 문서

 

하지만 viewIsAppearing 문서에 이런 내용이 있다.

 

"viewWill/DidLayoutSubviews는 transition이 진행되는 동안 여러 번 호출될 수 있다.

따라서 바텀시트는 한 번만 띄워야 하므로 viewDidLayoutSubviews는 적절하지 않다는 것을 알 수 있다.

viewIsAppearing 문서

 

실제로 아래처럼 바텀시트가 여러 번 띄워지는 것을 볼 수 있다.

(바텀시트가 숨겨지는 transaction에 의해 layoutSubviews, viewDidLayoutSubviews가 다시 호출된 듯)


결론 

storyboard를 연결한 viewController에만 발생하는 버그이지만

협업하는 팀원이 storyboard를 추가할 수도 있으니까

subview layout은 viewDidLoad 대신 viewWillAppear에서 잡는 것이 안전하다.

(다만, view constraints에 따라 layoutIfNeeded를 호출해야 할 수 있고,

viewController 전환 애니메이션과 분리해야 하는 경우 viewDidAppear에서 잡아야 할 수도 있다.)

 

그리고 당연한 거지만 viewWillAppear는 다른 화면으로 이동했다가 home으로 되돌아왔을 때 재호출되므로

앱을 실행했을 때 한 번만 노출하기 위해 
바텀시트가 띄워졌는지 여부를 homeVC의 프로퍼티로 저장하여 flag로 활용해야 한다.

 

참고로 바텀시트를 포함한 예제코드를 만들었는데, 위 버그가 재현되지 않아 한참 삽질을 했다.

알고 보니 storyboard에서 is initial view controller로 체크한 경우에는

viewDidLoad에서도 view width가 잘 잡히는 거였다.

직접 구현해보고 싶다면 SecondViewController를 push 해서 테스트해야 함을 잊지 말자!

 

예제 코드는 GitHub Repo에 올려두었다.

 

 

- Reference

 

🍎 포스트가 도움이 되었다면, 공감🤍 / 구독🍹 / 공유🔗 / 댓글✏️ 로 응원해주세요. 감사합니다.

Comments