본문으로 바로가기

SwiftUI halfmodal 구현 feat UIKit

category 카테고리 없음 2024. 6. 27. 06:47

iOS 15 이상부터는 하프모달을 지원하지만 그 이하버전에는 직접 구현해야 한다

처음에는 State에 따라 view를 보여주고 안보여주고 이런식으로 구현했으나 탭바가 있을경우 문제가 됐다

 

어차피 기존 UIKit 코드에서 사용하던 halfmodal도 있고해서 그냥 해당 코드를 SwiftUI에서도 사용할 수 있도록 마이그레이션했다

 

1. 기존 UIKit에서 사용하던 half modal

final class HalfModalViewController: UIViewController {
    
    private var contentView: UIView
    
    private let dimmedView: UIView = {
        let view = UIView()
        view.backgroundColor = .black
        view.alpha = 0
        
        return view
    }()
    
    private let containerView: UIView = {
        let view = UIView()
        view.layer.masksToBounds = true
        
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setSubViews()
        connectTarget()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        presentAnimation()
    }
    
    private let parentVC: UIViewController?
    private var currentHeight = CGFloat.zero
    private var defaultHeight = CGFloat.zero
    private var dismissHeight: CGFloat {
        return defaultHeight - 5
    }
    var isScrollEnable: Bool
    var isDimmTouched: Bool
    
    private var maximumHeight: CGFloat {
        return UIScreen.main.bounds.height - view.safeAreaInsets.top
    }
    
    init(contentView: UIView,
         parentVC: UIViewController?,
         isScrollEnable: Bool,
         isDimmTouched: Bool = true
    ) {
        self.isScrollEnable = isScrollEnable
        self.parentVC = parentVC
        self.contentView = contentView
        self.isDimmTouched = isDimmTouched
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - Method
    func showToast(style: AIMCareMSKToast.ToastStyle, message: String) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
            guard let self = self else { return }
            self.view.toast(style: style, message: message, bottomPosition: self.contentView.frame.height + 8, showDuration: 0.3, duration: 3)
        }
    }
    
    @discardableResult
    func changeView(view: UIView, isScrollEnable: Bool? = nil, isDimmTouched: Bool? = nil) -> HalfModalViewController {
        let halfModalVC = HalfModalViewController(contentView: view, parentVC: self.parentVC, isScrollEnable: isScrollEnable ?? self.isScrollEnable, isDimmTouched: isDimmTouched ?? self.isDimmTouched)
        halfModalVC.modalPresentationStyle = .overFullScreen

        self.dismiss(animated: false) { [weak self] in
            guard let self = self else {
                return
            }
            
            self.parentVC?.present(halfModalVC, animated: false)
        }
        
        return halfModalVC
    }
    
    private func presentAnimation(dimmViewAnimation: Bool = true) {
        containerView.snp.remakeConstraints {
            $0.bottom.horizontalEdges.equalToSuperview()
            $0.height.equalTo(contentView.snp.height)
        }
        
        UIView.animate(withDuration: 0.2 , delay: 0, options: .curveEaseOut) {
            self.view.layoutIfNeeded()
            if dimmViewAnimation {
                self.dimmedView.alpha = 0.8
            }
        } completion: { [weak self] isSuccess in
            guard let self = self else { return }
            if isSuccess {
                self.currentHeight = self.contentView.frame.height
                self.defaultHeight = self.contentView.frame.height
            }
        }
    }
    
//    override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
//        if flag {
//            dismissAnimation()
//        } else {
//            super.dismiss(animated: flag, completion: completion)
//        }
//    }
    
    func dismissAnimation(completion: (() -> Void)? = nil) {
        containerView.snp.remakeConstraints {
            $0.bottom.horizontalEdges.equalToSuperview()
            $0.height.equalTo(0)
        }
        
        UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
            self.view.layoutIfNeeded()
            self.dimmedView.alpha = 0
        } completion: { [weak self] _ in
            self?.dismiss(animated: false) {
                completion?()
            }
        }
    }
    
    private func heightConstraintsChangeAnimation(newHeight height: CGFloat) {
        contentView.snp.remakeConstraints {
            $0.horizontalEdges.bottom.equalToSuperview()
            $0.height.equalTo(height)
        }
        UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
            self.view.layoutIfNeeded()
        }
    }
    
    // MARK: - Method
    func pushViewController(_ vc: UIViewController) {
        dismiss(animated: false)
        parentVC?.navigationController?.pushViewController(vc, animated: true)
    }
    
    func presentViewController(_ vc: UIViewController) {
        dismiss(animated: false)
        parentVC?.present(vc, animated: true)
    }
    
    // MARK: - Target
    private func connectTarget() {
        let dimmedViewTapGesture = UITapGestureRecognizer(target: self, action: #selector(dimmedViewTapGestureAction(_:)))
        dimmedView.addGestureRecognizer(dimmedViewTapGesture)
        let contentViewPanGesture = UIPanGestureRecognizer(target: self, action: #selector(contentViewPanGestureAction(_:)))
        contentView.addGestureRecognizer(contentViewPanGesture)
    }
    
    @objc private func dimmedViewTapGestureAction(_ sender: UITapGestureRecognizer) {
        if isDimmTouched {
            dismissAnimation()
        }
    }
    
    @objc private func contentViewPanGestureAction(_ sender: UIPanGestureRecognizer) {
        let panOringin = sender.translation(in: view)
        let isDraggingDown = panOringin.y > 0
        let newHeight = currentHeight - panOringin.y
        
        switch sender.state {
        case .changed:
            if newHeight < maximumHeight, isScrollEnable {
                contentView.snp.remakeConstraints {
                    $0.bottom.horizontalEdges.equalToSuperview()
                    $0.height.equalTo(newHeight)
                }
            }
        case .ended:
            if newHeight < dismissHeight {
                dismissAnimation()
            } else if newHeight > defaultHeight, !isDraggingDown, isScrollEnable {
                heightConstraintsChangeAnimation(newHeight: maximumHeight)
                currentHeight = maximumHeight
            } else if newHeight > defaultHeight, isDraggingDown, isScrollEnable {
                heightConstraintsChangeAnimation(newHeight: defaultHeight)
                currentHeight = defaultHeight
            }
        default:
            break
        }
    }
    // MARK: - UI
    private func setSubViews() {
        view.backgroundColor = .clear
        [dimmedView, containerView].forEach {
            view.addSubview($0)
        }
        containerView.addSubview(contentView)
        setConstraints()
    }
    
    private func setConstraints() {
        dimmedView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        
        containerView.snp.makeConstraints {
            $0.bottom.horizontalEdges.equalToSuperview()
            $0.height.equalTo(0)
        }
        
        contentView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
}

 

2. View to UIView 코드

class HostingView<T: View>: UIView {
    
    private(set) var hostingController: UIHostingController<T>
    
    var rootView: T {
        get { hostingController.rootView }
        set { hostingController.rootView = newValue }
    }
    
    init(rootView: T, frame: CGRect = .zero) {
        hostingController = UIHostingController(rootView: rootView)
        
        super.init(frame: frame)
        
        backgroundColor = .clear
        hostingController.view.backgroundColor = backgroundColor
        hostingController.view.frame = self.bounds
        hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        
        addSubview(hostingController.view)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

 

3. SwiftUI의 View를 확장해서 halfModal 을 present해주는 코드

extension View {
	func presentHalfModal<Content: View>(
        height: CGFloat,
        scrollEnable: Bool = false,
        @ViewBuilder _ content: @escaping (() -> Content)
    ) {
        let view = HostingView(rootView: content())
        view.snp.makeConstraints {
            $0.width.equalTo(screenSize.width)
            $0.height.equalTo(height)
        }
        view.backgroundColor = .white
        view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
        view.layer.cornerRadius = 28
        view.layer.masksToBounds = true
        let vc = HalfModalViewController(contentView: view, parentVC: nil, isScrollEnable: scrollEnable)
        vc.modalPresentationStyle = .overFullScreen
        UIApplication.topViewController()?.present(vc, animated: false)
    }
}

 

 

SwiftUI의 버튼의 액션이나 상태가 변했을때 presentHalfModal 함수를 호출해주면 정상적으로 작동한다

 

SwiftUI같은경우 아직 버그도 존재하고 불편한점도 있기 때문에 UIKit을 잘 섞어서 사용하는게 효율적이라 생각한다