오티스의개발일기

SwiftUI BottomSheet 커스텀 바텀시트 만드는 방법 😆 [코드 제공] 본문

IOS 개발/BottomSheet

SwiftUI BottomSheet 커스텀 바텀시트 만드는 방법 😆 [코드 제공]

안되면 될때까지.. 2024. 12. 18. 15:11
728x90

안녕하세요

오늘은 커스텀 바텀시트를 만들어보려고합니다.

 

제가 만드려고하는 바텀시트는 기본적으로 UIKit를 사용해서 컨트롤러를 제작한후

시트자체는 UIKit
사용하는 View는 SwiftUI에서 제작한 View를 사용할수있도록 만드려고 합니다.

 

지금부터 만들 특별한 기능은 아래와 같습니다.

 

  • 바텀시트 드레그기능
  • 바텀시트 자동 높이조절기능
  • 바텀시트 핸들노출유무, 색변경
  • 바텀시트 색 변경
  • 바텀시트 뒤 배경색 변경
  • 바텀시트 배경색클릭시 dissmiss 콜백기능

 

 

미리보기

 

 

전체 코드 예제 깃헙

https://github.com/1domybest/CustomBottomSheet_example

 

GitHub - 1domybest/CustomBottomSheet_example

Contribute to 1domybest/CustomBottomSheet_example development by creating an account on GitHub.

github.com

 

 

바텀시트 라이브러리 깃헙

https://github.com/1domybest/CustomBottomSheetLibrary

 

GitHub - 1domybest/CustomBottomSheetLibrary

Contribute to 1domybest/CustomBottomSheetLibrary development by creating an account on GitHub.

github.com

 

 

바텀시트 내부에서 사용한 키보드 매니저 라이브러리 깃헙

https://github.com/1domybest/KeyboardManagerLibrary

 

GitHub - 1domybest/KeyboardManagerLibrary

Contribute to 1domybest/KeyboardManagerLibrary development by creating an account on GitHub.

github.com

 

 

프로젝트 구조

 

 

 

이 프로젝트는 기본적으로 UIKit를 베이스로하고 

SwiftUI를 사용하는 구조입니다.

자세한 기본구조는  아래 깃헙을 참고해주세요

 

https://github.com/1domybest/UIKit-SwiftUI_Project

 

GitHub - 1domybest/UIKit-SwiftUI_Project: The project is based on Storyboards but uses SwiftUI.

The project is based on Storyboards but uses SwiftUI. - 1domybest/UIKit-SwiftUI_Project

github.com

 

 

CustomUIKitBottomSheetOption

 

외부에서 사용할 시트의 옵션을 구조체로 만든 파일입니다

import Foundation
import SwiftUI
import UIKit

struct CustomUIKitBottomSheetOption {
    var pk: UUID
    var someView: AnyView
    var sheetMode: UIModalPresentationStyle = .custom
    // 핸들 가능 모드 custom, automatic , popover, pageSheet, formSheet
    // 핸들 불가능 모드 currentContext, fullScreen, overCurrentContext, overFullScreen,
    
    var sheetHeight: CGFloat = UIScreen.main.bounds.height / 2
    var dragAvailable: Bool = true
    var availableOutTouchClose: Bool = true
    var showHandler: Bool = true
    
    var backgroundColor: Color = Color.black.opacity(0.5)
    var handlerColor: Color = Color.gray4
    var sheetColor: Color = Color.white0
    var sheetShadowColor: Color = Color.gray
    
    
    var minimumHeight:CGFloat = 100.getHeightScaledDiagonal()
    var maximumHeight:CGFloat = UIScreen.main.bounds.height
    
    var hasKeyboard: Bool = false
    
    var availableHasHandle: Bool {
        return showHandler && (sheetMode == .custom ||
                               sheetMode == .automatic ||
                               sheetMode == .popover ||
                               sheetMode == .pageSheet ||
                               sheetMode == .formSheet )
    }
    
    var onScrolling: ((Double) -> Void)? = { _ in }
    var onEndTouchScrolling: ((Double, Bool) -> Void)? = { _,_ in }
    
    var onDismiss: (() -> Void)? = {}
}

 

CustomUIKitBottomSheet

중간에서 바텀시트를 담고있는 UIViewController입니다.

 

import UIKit
import SwiftUI

class CustomUIKitBottomSheet: UIViewController {
    
    var customUIKitBottomSheetOption: CustomUIKitBottomSheetOption?
    var customModalPresentationController: CustomModalPresentationController?
    
    private var keyboardHeightConstraint: NSLayoutConstraint?
    
    private var initialTouchPoint: CGPoint?
    
    
    var handlerView: UIView?
    var topSafeAreaSize: CGFloat = .zero
    var scrollView: UIScrollView?
    var hostingController: UIHostingController<AnyView>?
    
    var keyboardManager: KeyboardManager?
    var keyboardHeight: CGFloat = .zero
    var isKeyboardOpen: Bool = false
    var lastSizeOfScrollViewContentHeight: CGFloat = .zero
    var currentSizeOfScrollViewContentHeight: CGFloat = .zero
    
    var dragGesture: UIPanGestureRecognizer?
    
    var isScrolling: Bool = false
    var isTop: Bool = true
    var isBottom: Bool = false
    var isFinishedDragging: Bool = false
    var isOverScreen: Bool = false
    
    init(bottomSheetModel: CustomUIKitBottomSheetOption) {
        self.customUIKitBottomSheetOption = bottomSheetModel
        // 핸들 가능 모드 custom, automatic , popover, pageSheet, formSheet
        // 핸들 불가능 모드 currentContext, fullScreen, overCurrentContext, overFullScreen,
        super.init(nibName: nil, bundle: nil)
        self.modalPresentationStyle = self.customUIKitBottomSheetOption?.sheetMode ?? .custom
        
        if self.customUIKitBottomSheetOption?.sheetMode == .custom {
            self.transitioningDelegate = self
        }
        
        if self.customUIKitBottomSheetOption?.hasKeyboard ?? false {
            self.keyboardManager = KeyboardManager()
            self.keyboardManager?.setCallback(callback: self)
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    deinit {
        print("CustomUIKitBottomSheet deinit")
    }
    
    func unreference () {
        self.customUIKitBottomSheetOption = nil
        self.customModalPresentationController = nil
        
        if dragGesture != nil {
            view.removeGestureRecognizer(dragGesture!)
            dragGesture = nil
        }
        
        self.scrollView = nil
        self.hostingController = nil
        self.handlerView = nil
        
        if self.keyboardManager != nil {
            self.keyboardManager?.removeCallback()
            self.keyboardManager = nil
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setView()
        
        if self.customUIKitBottomSheetOption?.dragAvailable ?? false && self.customUIKitBottomSheetOption?.sheetMode == .custom {
            addGestureRecognizers()
        }
        
        if let rootViewController = UIApplication.shared.windows.first?.rootViewController {
            let safeAreaInsets = rootViewController.view.safeAreaInsets
            topSafeAreaSize = safeAreaInsets.top
         }
    }
    
    func setView () {
        if self.customUIKitBottomSheetOption?.sheetMode == .custom {
            setSheetView()
            setupScrollView()
        } else {
            setHostingView()
        }
        
//        setHostingView()
        
        
        if self.customUIKitBottomSheetOption?.showHandler ?? false || self.customUIKitBottomSheetOption?.availableHasHandle ?? false {
            setHandlerView()
        }
    }
    
    func addGestureRecognizers() {
         let dragGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
         view.addGestureRecognizer(dragGesture)
        self.dragGesture = dragGesture
     }
    
    func setupScrollView() {
        let scrollView = CustomScrollView()
        scrollView.backgroundColor = self.customUIKitBottomSheetOption?.sheetColor.getUIColor()
        scrollView.isScrollEnabled = false
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.alwaysBounceVertical = true
        scrollView.showsVerticalScrollIndicator = true
        scrollView.delegate = self
        view.addSubview(scrollView)
        
        var topPadding = self.customUIKitBottomSheetOption?.showHandler ?? false ? 40.0.getHeightScaledDiagonal() : 28.0.getHeightScaledDiagonal()
        
        if self.customUIKitBottomSheetOption?.sheetMode != .custom {
            topPadding = 0
        }
        
        // 오토레이아웃 설정
        NSLayoutConstraint.activate([
            scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: topPadding),
            scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            scrollView.widthAnchor.constraint(equalTo: view.widthAnchor)  // 가로 스크롤 방지
        ])
        
        // SwiftUI View를 AnyView로 감싸서 사용
        let swiftUIView = self.customUIKitBottomSheetOption?.someView
        
        let hostingController = UIHostingController(rootView: AnyView(swiftUIView))
        hostingController.view.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: self.customUIKitBottomSheetOption?.sheetHeight ?? .zero)
        
        scrollView.addSubview(hostingController.view)
    
        addChild(hostingController)
        hostingController.didMove(toParent: self)
        hostingController.view.backgroundColor = self.customUIKitBottomSheetOption?.sheetColor.getUIColor()
        self.hostingController = hostingController
        
        self.scrollView = scrollView
    }
    
    func setHostingView () {
        // SwiftUI View를 AnyView로 감싸서 사용
        let swiftUIView = self.customUIKitBottomSheetOption?.someView
        
        let hostingController = UIHostingController(rootView: AnyView(swiftUIView))
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        
        self.view.addSubview(hostingController.view)
        
        var topPadding = self.customUIKitBottomSheetOption?.showHandler ?? false ? 40.0.getHeightScaledDiagonal() : 28.0.getHeightScaledDiagonal()
        
        if self.customUIKitBottomSheetOption?.sheetMode != .custom {
            topPadding = 0
        }
        
        // 오토레이아웃 설정
        NSLayoutConstraint.activate([
            hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            hostingController.view.topAnchor.constraint(equalTo: view.topAnchor, constant: topPadding),
            hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            hostingController.view.widthAnchor.constraint(equalTo: view.widthAnchor)  // 가로 스크롤 방지
        ])
        

        addChild(hostingController)
        hostingController.didMove(toParent: self)
        self.hostingController = hostingController

    }
    
    func setSheetView () {
        if self.customUIKitBottomSheetOption?.sheetHeight == UIScreen.main.bounds.height { return }
        view.backgroundColor = self.customUIKitBottomSheetOption?.sheetColor.getUIColor()
        
        let radius: CGFloat = 20.0 // 원하는 radius 값으로 변경
        let corners: UIRectCorner = [.topLeft, .topRight]
        let path = UIBezierPath(roundedRect: view.bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
        let maskLayer = CAShapeLayer()
        maskLayer.path = path.cgPath
        
        self.view.layer.mask = maskLayer
    }
    
    func setHandlerView() {
        let handlerView = UIView()
        handlerView.backgroundColor = self.customUIKitBottomSheetOption?.handlerColor.getUIColor()
        handlerView.layer.cornerRadius = 2

        view.addSubview(handlerView)
        handlerView.translatesAutoresizingMaskIntoConstraints = false
        
        var topPadding = 8.0.getHeightScaledDiagonal()
        if self.customUIKitBottomSheetOption?.sheetMode != .custom ?? .custom {
            topPadding = 12.0.getHeightScaledDiagonal()
        }
        
        NSLayoutConstraint.activate([
            handlerView.widthAnchor.constraint(equalToConstant: 36),
            handlerView.heightAnchor.constraint(equalToConstant: 4),
            handlerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            handlerView.topAnchor.constraint(equalTo: view.topAnchor, constant: topPadding)
        ])
        
        self.handlerView = handlerView
    }
    
    func dismissPresent () {
        self.dismiss(animated: true, completion: {
            self.customUIKitBottomSheetOption?.onDismiss?()
            self.unreference()
        })
    }
    
    func dismissPresent (animated: Bool = true, completion: @escaping () -> Void) {
        self.dismiss(animated: animated, completion: {
            self.customUIKitBottomSheetOption?.onDismiss?()
            self.unreference()
            completion()
        })
    }

    
    func updateSheetHeight(newHeight: CGFloat) {
        self.currentSizeOfScrollViewContentHeight = newHeight
        var newHeight = newHeight
        
        // 이곳에서 max확인해야함
        let topPadding = self.customUIKitBottomSheetOption?.showHandler ?? false ? 40.0.getHeightScaledDiagonal() : 28.0.getHeightScaledDiagonal()
        newHeight += topPadding
        
        var adjustedLength = min(max(newHeight, self.customUIKitBottomSheetOption?.minimumHeight ?? .zero), self.customUIKitBottomSheetOption?.maximumHeight ?? .zero)
        
        var contentSize:CGFloat = adjustedLength
        
        if newHeight > UIScreen.main.bounds.height - (keyboardHeight) {
            // 넘어섰을때
            adjustedLength = UIScreen.main.bounds.height
            if keyboardHeight > 0 {
                adjustedLength = UIScreen.main.bounds.height - (keyboardHeight)
            }
            
            contentSize = newHeight
            self.isOverScreen = true
            self.scrollView?.isScrollEnabled = true
        } else {
            self.isOverScreen = false
            self.scrollView?.isScrollEnabled = false
        }

        DispatchQueue.main.async {
            self.customModalPresentationController?.setSheetHeight(sheetHeight: adjustedLength)
            self.scrollView?.contentSize = CGSize(width: self.view.frame.width, height: contentSize) // 테스트를 위한 고정된 크기
            self.hostingController?.view.frame.size = CGSize(width: self.view.frame.width, height: contentSize)
        }
    }
}

 

 

CustomModalPresentationController

실제 바텀시트인 UIPresentationController 입니다

import Foundation
import UIKit

// Custom PresentationController 정의
class CustomModalPresentationController: UIPresentationController {
    
    // 추가적인 파라미터
    var bottomSheetModel: CustomUIKitBottomSheetOption?
    var keyboardHeight: CGFloat = .zero
    
    // 초기화 메서드
    init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?, bottomSheetModel: CustomUIKitBottomSheetOption?) {
        self.bottomSheetModel = bottomSheetModel
        super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
    }
    
    deinit {
        print("CustomModalPresentationController deinit")
    }
    
    func unreference () {
        self.bottomSheetModel = nil;
    }
    
    func setKeyboardHeight(keyboardHeight: CGFloat) {
        self.keyboardHeight = keyboardHeight
    }
    
    // 바텀 시트 스타일을 위한 높이 설정
    override var frameOfPresentedViewInContainerView: CGRect {
        guard let containerView = containerView else { return .zero }
        let height: CGFloat = self.bottomSheetModel?.sheetHeight ?? .zero
        return CGRect(
            x: 0,
            y: containerView.bounds.height - (height + keyboardHeight),
            width: containerView.bounds.width,
            height: height
        )
    }
    
    // 배경을 어둡게 만드는 처리
    override func presentationTransitionWillBegin() {
        guard let containerView = containerView else { return }
        if self.bottomSheetModel?.backgroundColor == .clear { return }
        if self.bottomSheetModel?.sheetMode != .custom { return }
        
        let dimmingView = UIView(frame: containerView.bounds)
        dimmingView.backgroundColor = self.bottomSheetModel?.backgroundColor.getUIColor()
        dimmingView.alpha = 0 // 처음에는 투명하게 설정
        
        // 탭 제스처 인식기 추가
        if self.bottomSheetModel?.availableOutTouchClose ?? false {
            let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dimmingViewTapped))
            dimmingView.addGestureRecognizer(tapGesture)
        }
        
        containerView.insertSubview(dimmingView, at: 0)
        
        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
            dimmingView.alpha = 1.0
        }, completion: nil)
    }

    @objc func dimmingViewTapped() {
        // 탭 이벤트가 발생했을 때 수행할 동작 (예: 바텀 시트 닫기)
        CustomBottomSheetSingleTone.shared.hide(pk: self.bottomSheetModel?.pk)
    }

    
    override func dismissalTransitionWillBegin() {
        guard let dimmingView = containerView?.subviews.first else { return }
        
        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
            dimmingView.alpha = 0.0
        }, completion: { _ in
            dimmingView.removeFromSuperview()
        })
    }
    
    
    func setSheetHeight(sheetHeight: CGFloat) {
         // Update the height in the model
        DispatchQueue.main.async {
            self.bottomSheetModel?.sheetHeight = sheetHeight
            
            // Notify that the layout should be updated
            if let containerView = self.containerView {
                UIView.animate(withDuration: 0.3) {
                    self.presentedView?.frame = self.frameOfPresentedViewInContainerView
                    containerView.setNeedsLayout()
                    containerView.layoutIfNeeded()
                    

                }
            }
        }
     }
}

 

CustomBottomSheetSingleTone

실제로 SwiftUI에서 사용할수 있도록 만든 싱글톤형식의 인터페이스입니다

 

import Foundation
import UIKit

class CustomBottomSheetSingleTone {
    /// 싱글톤
    public static var shared = CustomBottomSheetSingleTone()
    
    var bottomSheetList: [(CustomUIKitBottomSheet, UUID, UUID?)] = [] // (시트 옵션, 시트 Pk, 호출하는 viwePk)
    var topViewController:UIViewController?
    private var bottomsheetQueue: DispatchQueue? = DispatchQueue(label: "bottomsheet.queue")
    
    func updateSheetHeight(pk: UUID, height:CGFloat) {
        // 필터링을 위해 for-in 루프와 enumerated()를 사용하여 인덱스와 요소를 동시에 가져옴
        for (index, (bottomSheetViewController, bottomSheetUUID, viewPk)) in bottomSheetList.enumerated().reversed() {
            if bottomSheetUUID == pk {
                bottomSheetViewController.updateSheetHeight(newHeight: height)
            }
        }
    }
    
    func findBottomSheet(pk: UUID?) -> CustomUIKitBottomSheet? {
        guard let sheetPk = pk else { return nil }
        
        for (index, (bottomSheetViewController, bottomSheetUUID, viewPk)) in self.bottomSheetList.enumerated().reversed() {
            if bottomSheetUUID == sheetPk {
                return bottomSheetViewController
            }
        }
        
        return nil
    }
    
    func hide(pk: UUID?) {
        bottomsheetQueue?.async {
            guard let sheetPk = pk else { return }
            DispatchQueue.main.async {
                // 필터링을 위해 for-in 루프와 enumerated()를 사용하여 인덱스와 요소를 동시에 가져옴
                for (index, (bottomSheetViewController, bottomSheetUUID, viewPk)) in self.bottomSheetList.enumerated().reversed() {
                    if bottomSheetUUID == sheetPk {
                        
                        // 배열에서 해당 요소를 제거
                        if self.bottomSheetList.indices.contains(index) {
                            // Alert를 dismiss 처리
                            self.bottomSheetList[index].0.dismissPresent()
                            self.bottomSheetList.remove(at: index)
                        } else {
                            print("Index \(index) is out of bounds.")
                        }
                    }
                }
                print("바텀시트 \(self.bottomSheetList)")
                // 배열이 비어 있으면 nil로 설정
                
                if self.bottomSheetList.isEmpty {
                    self.bottomSheetList = []
                    self.topViewController = nil
                }
            }
        }
       
        
    }
    
    func hide(sheetPk: UUID?, completion: @escaping () -> Void) {
        
        bottomsheetQueue?.async {
            guard let sheetPk = sheetPk else { return }
            
            DispatchQueue.main.async {
                for (index, (bottomSheetViewController, bottomSheetUUID, viewPk)) in self.bottomSheetList.enumerated().reversed() {
                    if bottomSheetUUID == sheetPk {
                        // Alert를 dismiss 처리
                        bottomSheetViewController.dismissPresent(completion: {
                            completion()
                        })
                        
                        // 배열에서 해당 요소를 제거
                        if self.bottomSheetList.indices.contains(index) {
                            self.bottomSheetList.remove(at: index)
                        } else {
                            print("Index \(index) is out of bounds.")
                        }
                    }
                }
                
                if self.bottomSheetList.isEmpty {
                    self.bottomSheetList = []
                    self.topViewController = nil
                }
                print("바텀시트 \(self.bottomSheetList)")
            }
        }
    }
    
    func hideAll(animated: Bool = true) {
        bottomsheetQueue?.async {
            DispatchQueue.main.async {
                for (index, (bottomSheetViewController, bottomSheetUUID, viewPk)) in self.bottomSheetList.enumerated().reversed() {
                    bottomSheetViewController.dismissPresent(animated: animated, completion: {
                    })
                }
                
                if self.bottomSheetList.isEmpty {
                    self.bottomSheetList = []
                    self.topViewController = nil
                }
            }
        }
    }
    
    func hideAll(viewPk: UUID?, animated: Bool = true) {
        print("방송종료 시트 호출됨")
        bottomsheetQueue?.async {
            guard let currentViewPk = viewPk else { return }
            
            DispatchQueue.main.async {
                for (index, (bottomSheetViewController, bottomSheetUUID, viewPk)) in self.bottomSheetList.enumerated().reversed() {
                    guard let viewPk = viewPk else { return }
                    if viewPk == currentViewPk {
                        bottomSheetViewController.dismissPresent(animated: animated, completion: {
                            if self.bottomSheetList.indices.contains(index) {
                                self.bottomSheetList.remove(at: index)
                            } else {
                                print("Index \(index) is out of bounds.")
                            }
                        })
                    }
                }
                
                if self.bottomSheetList.isEmpty {
                    self.bottomSheetList = []
                    self.topViewController = nil
                }
                
            }
        }
    }
    
    func show(customUIKitBottomSheetOption: CustomUIKitBottomSheetOption, sheetPk: UUID, viewPk: UUID?) {
        bottomsheetQueue?.async {
            DispatchQueue.main.async {
                let bottomSheetViewController = CustomUIKitBottomSheet(bottomSheetModel: customUIKitBottomSheetOption)
                
                guard let topController = self.getTopViewController() else { return }
                if self.bottomSheetList.isEmpty { self.topViewController = topController }
                
                topController.present(bottomSheetViewController, animated: true)
                self.bottomSheetList.append((bottomSheetViewController, sheetPk, viewPk))
            }
        }
    }
    
    private func getTopViewController() -> UIViewController? {
        guard let rootViewController = UIApplication.shared.connectedScenes
                .compactMap({ $0 as? UIWindowScene })
                .first?.windows
                .filter({ $0.isKeyWindow }).first?.rootViewController else {
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                     _ = self.getTopViewController()
                 }
            
            return nil
        }
        
        return getTopViewController(from: rootViewController)
    }
    
    private func getTopViewController(from rootViewController: UIViewController) -> UIViewController? {
        if let presentedViewController = rootViewController.presentedViewController {
            // rootViewController가 다른 뷰 컨트롤러를 표시 중이면, 그 뷰 컨트롤러를 최상단으로 확인
            return getTopViewController(from: presentedViewController)
        }
        
        // Navigation Controller가 있는 경우
        if let navigationController = rootViewController as? UINavigationController {
            return navigationController.visibleViewController
        }
        
        // Tab Bar Controller가 있는 경우
        if let tabBarController = rootViewController as? UITabBarController {
            if let selectedViewController = tabBarController.selectedViewController {
                return getTopViewController(from: selectedViewController)
            }
        }
        
        return rootViewController
    }
}

 

 

 

사용 방법

 

MainView

import Foundation
import SwiftUI

struct MainView: View {
    @ObservedObject var vm: MainViewModel = MainViewModel()
    var body: some View {
        ZStack {
            Color.black
            
            Button(action: {
                self.vm.openSwiftUIBottomSheet()
            }, label: {
                Text("바텀시트 열기")
                    .padding(.horizontal, 20)
                    .padding(.vertical, 15)
                    .foregroundColor(.white)
                    .background(
                        RoundedRectangle(cornerRadius: 10)
                            .foregroundColor(.blue)
                    )
            })
            
        }
    }
}

 

 

MainViewModel

import Foundation
import SwiftUI
import UIKit


class MainViewModel: ObservableObject {
    var vmPK: UUID = UUID() // viewModel의 고유 pk
    
    init() {
        
    }
    
    
    func openSwiftUIBottomSheet() {
        let bottomSheetPk = UUID()
        
        
        let view:AnyView = AnyView(
            CustomBottomSheetView(pk: bottomSheetPk)
        )
        
        var bottomSheetOption = CustomUIKitBottomSheetOption(pk: bottomSheetPk, someView: view)
        bottomSheetOption.sheetColor = .white
        bottomSheetOption.handlerColor = .black
        bottomSheetOption.dragAvailable = true
        bottomSheetOption.hasKeyboard = true
        bottomSheetOption.sheetHeight = 604.getHeightScaledDiagonal()
        bottomSheetOption.sheetMode = .custom
        
        self.openBottomSheet(bottomSheetOption: bottomSheetOption)
    }
    
    /// 바텀시트 열기
    func openBottomSheet(bottomSheetOption: CustomUIKitBottomSheetOption) {
        CustomBottomSheetSingleTone.shared.show(customUIKitBottomSheetOption: bottomSheetOption, sheetPk: bottomSheetOption.pk, viewPk: self.vmPK)
    }
    
    /// 열려있는 모든 바텀시트 닫기
    func hideAllBottomSheet() {
        CustomBottomSheetSingleTone.shared.hideAll(viewPk: self.vmPK, animated: false)
    }
    
}

 

 

 

 

실제 바텀시트의 UI를 담당하는 파일

 

CustomBottomSheetView

 

import Foundation
import SwiftUI
import CustomBottomSheetLibrary

struct CustomBottomSheetView: View {
    @ObservedObject var vm:CustomBottomSheetViewModel

    init(pk: UUID) {
        vm = CustomBottomSheetViewModel(pk: pk)
    }
    var body: some View {
        ZStack {
            VStack {
                Text("Hello, this is a SwiftUI View inside a UIViewController!")
                    .font(.title)
                    .foregroundColor(.red)
                    .padding()
                
                UIKitViewRepresentable(view: self.vm.textViewView)
                    .frame(height: self.vm.textViewHeight)
                
                Button(action: {
                    vm.extraHeight += 100
                    print("높이 토글")
                }) {
                    Text("시트 높이 늘리기")
                        .font(.headline)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
                
                UIKitViewRepresentable(view: self.vm.textFieldView)
                    .frame(height: 50)
                
                Button(action: {
//                    CustomBottomSheetSingleTone.shared.hide(pk: pk)
                    self.vm.keyboardManager?.hideKeyboard()
                }) {
                    Text("닫기 버튼")
                        .font(.headline)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
                
                Button(action: {
                    self.vm.openSheet()
                }) {
                    Text("추가 시트 버튼")
                        .font(.headline)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
                
                RoundedRectangle(cornerRadius: 20)
                    .frame(width: 20, height: vm.extraHeight)
                
                Button(action: {
                    CustomBottomSheetSingleTone.shared.hideAll()
                }) {
                    Text("모든 시트 닫기")
                        .font(.headline)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
            }
        }
        .background(
            GeometryReader { geo -> Color in
                DispatchQueue.main.async {
                    print("size: \(geo.size.height)")
                    if vm.hasChild { return }
                    if vm.lastHeight != geo.size.height {
                        self.vm.lastHeight = geo.size.height
                        CustomBottomSheetSingleTone.shared.updateSheetHeight(pk: self.vm.pk, height: geo.size.height)
                    }
                    
                }
                return Color.clear
            }
        )
        .ignoresSafeArea(.all)
        .ignoresSafeArea(.keyboard)
       
        
    }
}

 

 

CustomBottomSheetViewModel

//
//  CustomBottomSheetViewModel.swift
//  UIKitCustomBottomSheet
//
//  Created by 온석태 on 12/17/24.
//

import Foundation
import SwiftUI
import CustomBottomSheetLibrary
import KeyboardManager
import CustomTextFieldLibrary

class CustomBottomSheetViewModel: ObservableObject {
    var bottomSheetOption: CustomUIKitBottomSheetOption?
    var navigationController: NavigationController? // 네비게이션
    var customUIKitBottomSheet: CustomUIKitBottomSheet?
    var keyboardManager: KeyboardManager?
    
    var pk: UUID // 자신을 호출한 부모에서 생성한 자식의 고유 pk
    var hasChild: Bool = false // 자기자신이 또다른 바텀시트가 있는지에대한 여부
    
    /// 키보드관련 변수
    var lastOffset: CGFloat = .zero // 마지막 스크롤 오프셋
    var isUpScrolling: Bool = false // 위로 스크롤하는지에대한 여부
    
    
    @Published var lastHeight: CGFloat = .zero // 마지막 바텀시트의 높이
    @Published var extraHeight: CGFloat = .zero // 현재 바텀시트의 높이
    
    /* 텍스트 필드 */
    var textFieldView: SingleTextFieldContentView?
    @Published var textFieldText:String = "" // 제목 텍스트
    
    /* 텍스트 뷰 */
    var textViewView: SingleTextViewContentView?
    @Published var textViewHeight:CGFloat = 40 // 웰컴코멘트 동적 높이
    @Published var textViewText:String = "" // 웰컴코멘트 텍스트
    

    
    init(pk: UUID) {
        self.pk = pk
        keyboardManager = KeyboardManager()
        keyboardManager?.setCallback(callback: self)
        self.initializeField()
    }
    
    func initializeField () {

        var textViewOption = SingleTextViewOption()
        textViewOption.backgroundColor = .clear
        textViewOption.textColor = .white
        textViewOption.placeholder = "텍스트 뷰 플레이스 홀더"
        textViewOption.backgroundColor = .brown
        textViewOption.placeholderColor = .black
        textViewOption.enableAutoHeight = true
        textViewOption.minHeight = 40
        textViewOption.maxHeight = 72
        textViewOption.maximunLenght = 1000
        textViewOption.font = UIFont.systemFont(ofSize: 14)
        textViewOption.horizontalPadding = 14.getWidthScaledDiagonal()
        textViewOption.verticalPadding = 11.getHeightScaledDiagonal()
        textViewOption.onChangeHeight = {[ weak self ]  height in
            guard let self = self else { return }
            self.textViewHeight = height
        }
        
        textViewOption.onTextChanged = {[ weak self ]  text in
            guard let self = self else { return }
            self.textViewText = text
        }
        
        var textFieldOption = SingleTextFieldOption()
        textFieldOption.font = UIFont.systemFont(ofSize: 14)
        textFieldOption.textColor = .white
        textFieldOption.backgroundColor = .blue
        textFieldOption.placeholder = "텍스트 필드 플레이스 홀더"
        textFieldOption.placeholderColor = .black
        textFieldOption.leftPadding = 14.getWidthScaledDiagonal()
        textFieldOption.placeholderColor = .white.withAlphaComponent(0.5)
        textFieldOption.borderStyle = .none
        textFieldOption.onTextChanged = { [ weak self ] text in
            guard let self = self else { return }
            self.textFieldText = text
        }
        
       
        
        self.textViewView = SingleTextViewContentView(singleTextViewOption: textViewOption)
        self.textFieldView = SingleTextFieldContentView(singleTextFieldOption: textFieldOption)
    }
    
    func setNavigationController(navigationController: NavigationController) {
        self.navigationController = navigationController
    }
    
    func openSheet() {
        DispatchQueue.main.async {
            let pk = UUID()
            let bottomSheetModel = CustomUIKitBottomSheetOption(pk: pk, someView: AnyView(CustomBottomSheetView(pk: pk)))
            self.bottomSheetOption = bottomSheetModel
            self.bottomSheetOption?.backgroundColor = .blue.opacity(0.5)
            self.bottomSheetOption?.availableOutTouchClose = true
            self.bottomSheetOption?.sheetColor = .red
            self.bottomSheetOption?.hasKeyboard = true
            self.bottomSheetOption?.handlerColor = .black
            self.bottomSheetOption?.dragAvailable = true
            self.bottomSheetOption?.sheetMode = .custom
            self.bottomSheetOption?.sheetHeight = 300
            self.bottomSheetOption?.onDismiss = {
                self.hasChild = false
            }
            
            if let bottomSheetModel = self.bottomSheetOption {
                let pk = bottomSheetModel.pk
                self.hasChild = true
                CustomBottomSheetSingleTone.shared.show(customUIKitBottomSheetOption: bottomSheetModel, sheetPk: pk, viewPk: UUID())
            }
        }
        
    }
    
    func onViewAppear() {
        DispatchQueue.main.async {
            if self.customUIKitBottomSheet == nil {
                self.customUIKitBottomSheet = CustomBottomSheetSingleTone.shared.findBottomSheet(pk: self.pk)
                self.customUIKitBottomSheet?.customUIKitBottomSheetOption?.onScrolling = { [weak self] offset in
                    guard let self = self else { return }
                    if offset > self.lastOffset {
                        // 위로 스크롤 중
                        self.isUpScrolling = true
                    } else if offset < self.lastOffset {
                        // 아래로 스크롤 중
                        self.isUpScrolling = false
                    }
                    
                    self.lastOffset = offset
                }
                
                self.customUIKitBottomSheet?.customUIKitBottomSheetOption?.onEndTouchScrolling = { [weak self] offset, decelerate in
                    guard let self = self else { return }
                    if !self.isUpScrolling {
                        if ((self.keyboardManager?.isKeyboardShown) != nil) {
                            DispatchQueue.main.async {
                                self.keyboardManager?.hideKeyboard()
                            }
                        }
                    }
                }
            }
        }
    }
    
    func onViewDisAppear() {
//        self.unreference()
    }
}


extension CustomBottomSheetViewModel: KeyboardManangerProtocol {
    func keyBoardWillShow(notification: NSNotification, keyboardHeight: CGFloat) {
        print("키보드 열림")
        print("키보드 의 높이 \(keyboardHeight)")
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
            self.customUIKitBottomSheet?.scrollToBottom(animated: true)
        }
        
    }
    
    func keyBoardWillHide(notification: NSNotification) {
        print("키보드 닫힘")
    }
}

 

 

 

자세한 코드는 아래 깃헙을 참고해주세요!

 

전체 코드 예제 깃헙

https://github.com/1domybest/CustomBottomSheet_example

 

GitHub - 1domybest/CustomBottomSheet_example

Contribute to 1domybest/CustomBottomSheet_example development by creating an account on GitHub.

github.com

 

 

바텀시트 라이브러리 깃헙

https://github.com/1domybest/CustomBottomSheetLibrary

 

GitHub - 1domybest/CustomBottomSheetLibrary

Contribute to 1domybest/CustomBottomSheetLibrary development by creating an account on GitHub.

github.com

 

 

바텀시트 내부에서 사용한 키보드 매니저 라이브러리 깃헙

https://github.com/1domybest/KeyboardManagerLibrary

 

GitHub - 1domybest/KeyboardManagerLibrary

Contribute to 1domybest/KeyboardManagerLibrary development by creating an account on GitHub.

github.com

 

 

바텀시트 내부에서 사용한 키보드 텍스트 [필드, 뷰] 관련 깃헙

https://github.com/1domybest/CustomTextFieldLibrary

 

GitHub - 1domybest/CustomTextFieldLibrary

Contribute to 1domybest/CustomTextFieldLibrary development by creating an account on GitHub.

github.com

 

 

 

키보드 관련글

https://otis.tistory.com/entry/SwiftUI-%ED%82%A4%EB%B3%B4%EB%93%9C-%EB%A7%A4%EB%8B%88%EC%A0%80-%EB%A7%8C%EB%93%A4%EA%B8%B0%F0%9F%98%80-%EC%BD%94%EB%93%9C-%EC%A0%9C%EA%B3%B5

 

SwiftUI 키보드 매니저 만들기😀 [코드 제공]

안녕하세요!오늘은 키보드를 열고 닫고혹은 키보드의 상태를 알수있는 코드를 작성하려고합니다.!!!기능으로는현재 키보드가 열리는 상태와 닫히는 상태를 콜백을통해 정보를 response받고그 정

otis.tistory.com

 

 

텍스트 필드 관련 글

 

https://otis.tistory.com/entry/SwiftUI-%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9%EC%9D%B4-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%BB%A4%EC%8A%A4%ED%85%80-%ED%85%8D%EC%8A%A4%ED%8A%B8-%ED%95%84%EB%93%9C-%ED%85%8D%EC%8A%A4%ED%8A%B8-%EB%B7%B0-%EB%A7%8C%EB%93%9C%EB%8A%94%EB%B0%A9%EB%B2%95-%F0%9F%A5%B0-%EC%BD%94%EB%93%9C%EC%A0%9C%EA%B3%B5-UIKit-Custom-TextField-UIKit-Custom-TextView

 

SwiftUI 에서 사용이 가능한 커스텀 텍스트 필드, 텍스트 뷰 만드는방법 🥰 [코드제공] [UIKit Custom T

안녕하세요 오늘은 SwiftUI에서 사용할수있는 텍스트 필드와 뷰에 관해 포스팅하려고합니다 ㅎㅎㅎ! 이 매니저를 만들게된 계기는제가 진행하고있는 프로젝트의 UI베이스는 SwiftUI이고미니멈 타

otis.tistory.com

 

 

 

 

728x90
Comments