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을 잘 섞어서 사용하는게 효율적이라 생각한다