본문 바로가기

Swift/UIKit

높이 조절 가능한 CustomModal(Half Modal)

Swift에는 present할 때 화면 전체가 아니라 반만 차지하게 하도록 하는 기능이 있다

(swift half modal이라고 치면 한글 블로그도 디게 많으니 궁금하면 검색 ㄱㄱ)

 

그런데 이게 15부터만 사용이 가능하다

근데 우리 회사는 최소 타겟이 15라서 그냥 바로 사용하려 했는데 이게 왠걸...

진짜 진짜 half, 화면의 반 아니면 완전크게 이런식으로만 사용 가능한게 15부터고 크기를 커스텀 하려면 16부터 라는 것이다...ㄷㄷ

 

그래서 그냥 만들었다

    @objc private func touchButton() {
        let customModal = CustomHalfModal()
        customModal.contentsView = TestView()
        
        self.present(customModal, animated: false)
    }

위 코드 처럼 뷰만 넣어주면 해당 뷰 크기만큼 모달이 알아서 나오는 기능을

(여기서 animated를 false로 한 것을 꼭 기억하길!)

 

영상속 TestView(빨간 뷰)는 높이가 600인 뷰이고 그에 따라 모달의 크기도 600이 된다

(사실 모달의 크기는 전체 화면이지만 모달 내부의 contentsView의 크기가 600으로 지정이 된다)

 

전체 코드를 먼저 넣을까 어쩔까 하다가 핵심 코드들만 설명하고 맨 아래에 전체 코드 넣을 예정

 

일단 기본적으로 snapKit을 썼다 그래서 다 snp로 레이아웃을 잡으니 참고하시길

 

final class CustomHalfModal: UIViewController {
    init() {
        super.init(nibName: nil, bundle: nil)
        self.modalPresentationStyle = .overFullScreen
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

 

일단 CustomHalfModal class를 만들어준다

modalPresentationStyle을 overFullScreen으로 하는 이유는 뒤에 화면이 사라지면 안되고 그냥 위에 덮어져야 하기 때문

    var contentsView: UIView?
    var animationDuration = 0.3
    var blurViewOpacity = 0.7
    var grabberIsHidden = false {
        didSet {
            grabberView.backgroundColor = grabberIsHidden ? .clear : .systemGray4
        }
    }

 

그리고 모달을 구성하는데에 필요한 변수들을 선언해준다

다 기본값이 있는데 contetnsView만 optional로 선언되어 있다

그러네... 지금 글 쓰면서 생각해봤는데 그냥 UIView()이렇게 초기화 시켜놔도 상관없다

    private lazy var blurBackView = {
        let button = UIView()
        button.backgroundColor = .black
        button.layer.opacity = 0
        
        return button
    }()

 

이것은 모달이 올라올 때 뒤에 배경을 흐리멍텅하게 해주는 역할을 한다

    private func setLayout() {
        self.view.addSubview(blurBackView)
        blurBackView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        
        if let contentsView = contentsView {
            contentsView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
            contentsView.layer.cornerRadius = 6
            contentsView.clipsToBounds = true
            self.view.addSubview(contentsView)
            contentsView.snp.makeConstraints {
                $0.top.equalTo(self.view.snp.bottom)
                $0.leading.trailing.equalToSuperview()
            }
       }

 

자 그러면 레이아웃을 잡아준다

contentsView는 외부에서 받는 뷰인데 없으면 등록이 안되고 그냥 blurView만 나올것이다

contentsView의 top을 bottom에 맞춰준 이유는

올라오는 애니메이션을 구현하기 위함이다

    func animate(isShow: Bool, duration: Double) {
        if isShow {
            UIView.animate(withDuration: duration) { [weak self] in
                self?.blurBackView.layer.opacity = Float(self?.blurViewOpacity ?? 0)
                if let contentsView = self?.contentsView {
                    contentsView.snp.remakeConstraints {
                        $0.leading.trailing.bottom.equalToSuperview()
                    }
                }
                self?.view.layoutIfNeeded()
            }
        } else {
            UIView.animate(withDuration: duration) { [weak self] in
                self?.blurBackView.layer.opacity = 0
                if let contentsView = self?.contentsView,
                   let view = self?.view {
                    contentsView.snp.remakeConstraints {
                        $0.top.equalTo(view.snp.bottom)
                        $0.leading.trailing.equalToSuperview()
                    }
                }
                self?.view.layoutIfNeeded()
            } completion: { [weak self] _ in
                self?.dismiss(animated: false)
            }
        }
    }

 

그리고 애니메이션 코드를 작성한다.

맨 처음 코드에 설명한대로 animated를 false로 한 이유는 CustomHalfModal class내부에서 애니메이션을 처리해주기 위함이다

 

그리고 애니 메이션이 다 끝난 뒤에 dismiss를 해준다

 

    override func viewDidLoad() {
        super.viewDidLoad()
        setLayout()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        animate(isShow: true, duration: self.animationDuration)
    }

그러면 setLayout 메서드를 viewDidLoad에서

animate코드를 viewDidAppear에서 호출해준다

이유는 뷰가 자리를 잡고 화면에 보이고 난 뒤에 애니메이션을 시작해 자연스럽게 보이게 하기 위함이다

 

 

자 그르면 이제 이렇게 애니메이션이 나온다

아직 닫는 기능을 안만들어서 닫히진않는다

    private lazy var gestureView = {
        let view = UIView()
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissModal(_:)))
        view.addGestureRecognizer(tapGesture)
        
        return view
    }()
    
        @objc private func dismissModal(_ gesture: UITapGestureRecognizer) {
        animate(isShow: false, duration: animationDuration)
    }
    
    //
    
                self.view.addSubview(gestureView)
            gestureView.snp.makeConstraints {
                $0.top.equalToSuperview()
                $0.leading.trailing.equalToSuperview()
                $0.bottom.equalTo(contentsView.snp.top).inset(28)
            }

 

자이제 닫는 기능을 추가 해 줄건데 gestureView를 만들어주고 setLayout가장 하단에 추가해준다

가장 하단에 추가해주는 이유는 해당 뷰가 가장 상단에 있어야 하기 때문이다

왜냐하면 28의 inset만큼 contentsView위를 넘겨서 자리를 잡을 것이기 때문이다

(contetnsView를 당겼을 때는 안닫히는데 상단 혹은 위에 여백을 당겼을 때는 닫히게 하기 위함인데 범위는 마음대로 지정해도 된다)

 

    private lazy var gestureView = {
        let view = UIView()
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissModal(_:)))
        view.addGestureRecognizer(tapGesture)
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(slideModal(_:)))
        view.addGestureRecognizer(panGesture)
        
        return view
    }()
    
        private var startSlideY = 0.0
    
    @objc private func slideModal(_ gesture: UIPanGestureRecognizer) {
        guard let contentsView = self.contentsView else {
            return
        }
        
        let touchPoint = gesture.location(in: view.window)
        switch gesture.state {
        case .began:
            self.startSlideY = touchPoint.y
        case .changed:
            slideAction(startSlideY: startSlideY, yPoint: touchPoint.y, contentsView: contentsView)
        case .ended, .cancelled:
            endAction(startSlideY: startSlideY,
                      yPoint: touchPoint.y,
                      contentsView: contentsView,
                      velocity: gesture.velocity(in: view).y)
        default:
            break
        }
    }

 

자 여기가 완전 핵심 코드인데 일단 switch문 내부에서 실행하는 메서드는 아래에서 설명하도록 하겠다

 

먼저 touchPoint => 사용자가 드래그를 하면서 움직이는 지점이다

터지가 이제 막 시작된 began에서는 startSlideY변수에 시작 지점을 저장해 준다

그리고 드래그 하는 중인 changed에서는 뷰가 작아지고 커지고 어쩌고를 해주면되고

마지막으로 드래그가 끝나는 시점(ended랑 cancelled)에서는 지금 현재 조건을 고려해서 다시 원래 크기로 되돌릴지 아니면 dimiss할지를 결정하면 된다

 

 

    private func slideAction(startSlideY: Double, yPoint: Double, contentsView: UIView) {
        if startSlideY < yPoint {
            let inset = yPoint - startSlideY
            contentsView.snp.updateConstraints {
                $0.bottom.equalToSuperview().inset(-inset)
            }
            blurBackView.layer.opacity = Float(blurViewOpacity - blurViewOpacity * inset / contentsView.frame.height)
        }
    }

 

자 일단 yPoint(현재 손의 위치, 드래그 위치)가 시작점 보다 클 경우. 즉, 아래에 있을 경우에만 작동하게한다

(위로 올라간다고 키울거 아니니까)

 

사실 말로는 키운다고 했지만 bottom을 superView에서 점점 아래로 내려버리는 거다

그와 함께 blurView의 opacity를 조정해준다

 

    private func endAction(startSlideY: Double, yPoint: Double, contentsView: UIView, velocity: Double) {
        let inset = yPoint - startSlideY
        let contentsViewHeight = contentsView.frame.height
        if contentsViewHeight / 2 < inset {
            animate(isShow: false, duration: 0.3)
        } else {
            if velocity > 2000 {
                animate(isShow: false, duration: 0.2)
            } else {
                animate(isShow: true, duration: 0.1)
            }
        }
    }

 

endAction에서는 slideAction과 다르게 velocity도 받아준다

유저가 빠르게 아래로 내렸을 경우 dismiss하기 위함이다(속도는 몇번 해보니 2000정도가 적당한 것 같은데 해보고 적절한대로 조정 ㄱㄱ)

 

자 일단 첫 분기는 화면에서 contentsView가 내려간 간격이 원래 뷰의 높이의 반 보다 많이 내려간 경우에 드래그가 끝났다면 닫아버린다

 

그게 아니라면(반보다 덜 드래그 했다면)

 

속도가 2000이상으로 드래가 끝났다? => 그럼 유저가 닫으려는 의도가 있다 생각하고 그냥 닫아버린다

그것도 아니라면? => 원상 복구 시킨다

 

 

끝!

 

전체코드(위에 설명 안한 grabber란 뷰가 있는데 이건 그냥 contentsView 상단에 추가해준 막대 뷰다)

아래처럼 구현하면 맨 위에 영상처럼 나온다

//
//  CustomHalfModal.swift
//  Oh-Sobi
//
//  Created by Doogie on 2/13/24.
//

import UIKit
import SnapKit

final class CustomHalfModal: UIViewController {
    var contentsView: UIView?
    var animationDuration = 0.3
    var blurViewOpacity = 0.7
    var grabberIsHidden = false {
        didSet {
            grabberView.backgroundColor = grabberIsHidden ? .clear : .systemGray4
        }
    }
    
    init() {
        super.init(nibName: nil, bundle: nil)
        self.modalPresentationStyle = .overFullScreen
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private lazy var blurBackView = {
        let button = UIView()
        button.backgroundColor = .black
        button.layer.opacity = 0
        
        return button
    }()
    
    private lazy var gestureView = {
        let view = UIView()
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissModal(_:)))
        view.addGestureRecognizer(tapGesture)
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(slideModal(_:)))
        view.addGestureRecognizer(panGesture)
        
        return view
    }()
    
    @objc private func dismissModal(_ gesture: UITapGestureRecognizer) {
        animate(isShow: false, duration: animationDuration)
    }
    
    private var startSlideY = 0.0
    
    @objc private func slideModal(_ gesture: UIPanGestureRecognizer) {
        guard let contentsView = self.contentsView else {
            return
        }
        
        let touchPoint = gesture.location(in: view.window)
        switch gesture.state {
        case .began:
            self.startSlideY = touchPoint.y
        case .changed:
            slideAction(startSlideY: startSlideY, yPoint: touchPoint.y, contentsView: contentsView)
        case .ended, .cancelled:
            endAction(startSlideY: startSlideY,
                      yPoint: touchPoint.y,
                      contentsView: contentsView,
                      velocity: gesture.velocity(in: view).y)
        default:
            break
        }
    }
    
    private func slideAction(startSlideY: Double, yPoint: Double, contentsView: UIView) {
        if startSlideY < yPoint {
            let inset = yPoint - startSlideY
            contentsView.snp.updateConstraints {
                $0.bottom.equalToSuperview().inset(-inset)
            }
            blurBackView.layer.opacity = Float(blurViewOpacity - blurViewOpacity * inset / contentsView.frame.height)
        }
    }
    
    private func endAction(startSlideY: Double, yPoint: Double, contentsView: UIView, velocity: Double) {
        let inset = yPoint - startSlideY
        let contentsViewHeight = contentsView.frame.height
        if contentsViewHeight / 2 < inset {
            animate(isShow: false, duration: 0.3)
        } else {
            if velocity > 2000 {
                animate(isShow: false, duration: 0.2)
            } else {
                animate(isShow: true, duration: 0.1)
            }
        }
    }
    
    private lazy var grabberView = {
        let view = UIView()
        view.backgroundColor = .systemGray4
        
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setLayout()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        animate(isShow: true, duration: self.animationDuration)
    }
    
    func animate(isShow: Bool, duration: Double) {
        if isShow {
            UIView.animate(withDuration: duration) { [weak self] in
                self?.blurBackView.layer.opacity = Float(self?.blurViewOpacity ?? 0)
                if let contentsView = self?.contentsView {
                    contentsView.snp.remakeConstraints {
                        $0.leading.trailing.bottom.equalToSuperview()
                    }
                }
                self?.view.layoutIfNeeded()
            }
        } else {
            UIView.animate(withDuration: duration) { [weak self] in
                self?.blurBackView.layer.opacity = 0
                if let contentsView = self?.contentsView,
                   let view = self?.view {
                    contentsView.snp.remakeConstraints {
                        $0.top.equalTo(view.snp.bottom)
                        $0.leading.trailing.equalToSuperview()
                    }
                }
                self?.view.layoutIfNeeded()
            } completion: { [weak self] _ in
                self?.dismiss(animated: false)
            }
        }
    }
    
    private func setLayout() {
        self.view.addSubview(blurBackView)
        blurBackView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        
        if let contentsView = contentsView {
            contentsView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
            contentsView.layer.cornerRadius = 6
            contentsView.clipsToBounds = true
            self.view.addSubview(contentsView)
            contentsView.snp.makeConstraints {
                $0.top.equalTo(self.view.snp.bottom)
                $0.leading.trailing.equalToSuperview()
            }
            
            if !grabberIsHidden {
                contentsView.addSubview(grabberView)
                
                grabberView.snp.makeConstraints {
                    $0.top.equalToSuperview().inset(12)
                    $0.centerX.equalToSuperview()
                    $0.height.equalTo(4)
                    $0.width.equalTo(80)
                }
            }
            
            self.view.addSubview(gestureView)
            gestureView.snp.makeConstraints {
                $0.top.equalToSuperview()
                $0.leading.trailing.equalToSuperview()
                $0.bottom.equalTo(contentsView.snp.top).inset(28)
            }
        }
    }
}

 

 

 

* 2024 02 26 추가

protocol CustomHalfModalDelegate: AnyObject {
    func animate(isShow: Bool, duration: Double)
}

final class CustomHalfModal: UIViewController, CustomHalfModalDelegate {
.
.
.
.
}


final class SomeView: UIView {
    private weak var halfModalDelegate: CustomHalfModalDelegate?
    
    func someAction() {
            halfModalDelegate?.animate(isShow: false, duration: 0.3)
            }
 }

 

half모달을 생성해서 사용하는게 아닌 사용할 때 마다 생성하며 뷰를 넘겨주는거다 보니 외부 조건에 의해 닫는 방법이 없다 (예를 들어 안에 리스트 중 하나 선택시 닫힘 등)

그래서 이렇게 delegate를 만들어줬다

전달되는 contentsView 안에 delegate선언 후 사용하면 된다

(외부에서 주입은 해줘야함)