관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 커스텀 팝업 창 (custom popup, custom alert view) 본문

iOS 응용 (swift)

[iOS - swift] 커스텀 팝업 창 (custom popup, custom alert view)

jake-kim 2020. 11. 28. 14:55

커스텀 팝업 창 구현 아이디어

  • UIViewController를 상속받아서 backgroundColror는 어둡게하고, 그 위에 customView를 띄우는 방식
  • present로 통째로 띄우면 팝업이 표출되는 현상처럼 보이는 것을 활용
  • 사용할때마다 객체로 만들며, UIViewController의 extension으로 넣고 ViewController에서 호출하여 사용

팝업을 띄우는 PopUpViewController 구현

  • 필요한 stored property 프로퍼티
    • 모두 optional로 설정하고 생성자에서 값을 받아서 초기화 > 만약 nil값이면 팝업 view에 addSubview를 하지 않게하여 표출되지 않게끔 설정
class PopUpViewController: UIViewController {
    private var titleText: String?
    private var messageText: String?
    private var attributedMessageText: NSAttributedString?
    private var contentView: UIView?
}
  • 팝업 UI 구성: containerView, containerStackView, buttonStackView로 나누어서 구성
    • containerView: containerStackView와 buttonStackView를 감싸서, 버튼, 타이틀 밖의 여백을 유지하기 위한 뷰
    • containerStackView: 타이틀, buttonStackView를 감싸는 뷰
    • buttonStackView: 버튼 2개를 가지고 있는 뷰
private lazy var containerView: UIView = {
    let view = UIView()
    view.backgroundColor = .white
    view.layer.cornerRadius = 8
    
    /// 팝업이 등장할 때(viewWillAppear)에서 containerView.transform = .identity로 하여 애니메이션 효과 주는 용도
    view.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)

    return view
}()

private lazy var containerStackView: UIStackView = {
    let view = UIStackView()
    view.axis = .vertical
    view.spacing = 12.0
    view.alignment = .center

    return view
}()

private lazy var buttonStackView: UIStackView = {
    let view = UIStackView()
    view.spacing = 14.0
    view.distribution = .fillEqually

    return view
}()
  • title과 message label 정의
private lazy var titleLabel: UILabel? = {
    let label = UILabel()
    label.text = titleText
    label.textAlignment = .center
    label.font = .systemFont(ofSize: 18.0, weight: .bold)
    label.numberOfLines = 0
    label.textColor = .black

    return label
}()

private lazy var messageLabel: UILabel? = {
    guard messageText != nil || attributedMessageText != nil else { return nil }

    let label = UILabel()
    label.text = messageText
    label.textAlignment = .center
    label.font = .systemFont(ofSize: 16.0)
    label.textColor = .gray
    label.numberOfLines = 0

    if let attributedMessageText = attributedMessageText {
        label.attributedText = attributedMessageText
    }

    return label
}()
  • 버튼은 따로 메소드에서 생성되게끔 설정 (UIViewController의 extension에서 title, titleColor 등을 주입하여 편리하게 생성하여 사용할수 있도록 구현
    • button.addAction클로저와 image() 메소드는 맨 아래에 extension으로 기능 구현
public func addActionToButton(title: String? = nil,
                              titleColor: UIColor = .white,
                              backgroundColor: UIColor = .blue,
                              completion: (() -> Void)? = nil) {
    let button = UIButton()
    button.titleLabel?.font = .systemFont(ofSize: 16.0, weight: .bold)

    // enable
    button.setTitle(title, for: .normal)
    button.setTitleColor(titleColor, for: .normal)
    button.setBackgroundImage(backgroundColor.image(), for: .normal)

    // disable
    button.setTitleColor(.gray, for: .disabled)
    button.setBackgroundImage(UIColor.gray.image(), for: .disabled)

    // layer
    button.layer.cornerRadius = 4.0
    button.layer.masksToBounds = true

    button.addAction(for: .touchUpInside) { _ in
        completion?()
    }

    buttonStackView.addArrangedSubview(button)
}
  • init 구현
    • modalPresentaionStyle = .overFullScreen을 통해 해당 뷰가 present될때 화면을 덮도록 설정 (디폴트값은 pageSheet)
convenience init(titleText: String? = nil,
                 messageText: String? = nil,
                 attributedMessageText: NSAttributedString? = nil) {
    self.init()

    self.titleText = titleText
    self.messageText = messageText
    self.attributedMessageText = attributedMessageText
    /// present 시 fullScreen (화면을 덮도록 설정) -> 설정 안하면 pageSheet 형태 (위가 좀 남아서 밑에 깔린 뷰가 보이는 형태)
    modalPresentationStyle = .overFullScreen
}

convenience init(contentView: UIView) {
    self.init()

    self.contentView = contentView
    modalPresentationStyle = .overFullScreen
}
  • viewWillAppear, viewWillDisappear에서 팝업 표출/dismiss 애니메이션 구현
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    // curveEaseOut: 시작은 천천히, 끝날 땐 빠르게
    UIView.animate(withDuration: 0.1, delay: 0.0, options: .curveEaseOut) { [weak self] in
        self?.containerView.transform = .identity
        self?.containerView.isHidden = false
    }
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)

    // curveEaseIn: 시작은 빠르게, 끝날 땐 천천히
    UIView.animate(withDuration: 0.1, delay: 0.0, options: .curveEaseIn) { [weak self] in
        self?.containerView.transform = .identity
        self?.containerView.isHidden = true
    }
}
  • UI 레이아웃, 기본 설정
override func viewDidLoad() {
    super.viewDidLoad()

    setupViews()
    addSubviews()
    makeConstraints()
}

private func setupViews() {
    view.addSubview(containerView)
    containerView.addSubview(containerStackView)
    view.backgroundColor = .black.withAlphaComponent(0.2)
}

private func addSubviews() {
    view.addSubview(containerStackView)

    if let contentView = contentView {
        containerStackView.addSubview(contentView)
    } else {
        if let titleLabel = titleLabel {
            containerStackView.addArrangedSubview(titleLabel)
        }

        if let messageLabel = messageLabel {
            containerStackView.addArrangedSubview(messageLabel)
        }
    }

    if let lastView = containerStackView.subviews.last {
        containerStackView.setCustomSpacing(24.0, after: lastView)
    }

    containerStackView.addArrangedSubview(buttonStackView)
}

private func makeConstraints() {
    containerView.translatesAutoresizingMaskIntoConstraints = false
    containerStackView.translatesAutoresizingMaskIntoConstraints = false
    buttonStackView.translatesAutoresizingMaskIntoConstraints = false

    NSLayoutConstraint.activate([
        containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 26),
        containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -26),
        containerView.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor, constant: 32),
        containerView.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor, constant: -32),

        containerStackView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 24),
        containerStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 24),
        containerStackView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -24),
        containerStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -24),

        buttonStackView.heightAnchor.constraint(equalToConstant: 48),
        buttonStackView.widthAnchor.constraint(equalTo: containerStackView.widthAnchor)
    ])
}
  • 위에서 필요한 메소드 extension으로 정의
// MARK: - Extension

extension UIColor {
    /// Convert color to image
    func image(_ size: CGSize = CGSize(width: 1, height: 1)) -> UIImage {
        return UIGraphicsImageRenderer(size: size).image { rendererContext in
            self.setFill()
            rendererContext.fill(CGRect(origin: .zero, size: size))
        }
    }
}

extension UIControl {
    public typealias UIControlTargetClosure = (UIControl) -> ()

    private class UIControlClosureWrapper: NSObject {
        let closure: UIControlTargetClosure
        init(_ closure: @escaping UIControlTargetClosure) {
            self.closure = closure
        }
    }

    private struct AssociatedKeys {
        static var targetClosure = "targetClosure"
    }

    private var targetClosure: UIControlTargetClosure? {
        get {
            guard let closureWrapper = objc_getAssociatedObject(self, &AssociatedKeys.targetClosure) as? UIControlClosureWrapper else { return nil }
            return closureWrapper.closure

        } set(newValue) {
            guard let newValue = newValue else { return }
            objc_setAssociatedObject(self, &AssociatedKeys.targetClosure, UIControlClosureWrapper(newValue),
                                     objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }

    @objc func closureAction() {
        guard let targetClosure = targetClosure else { return }
        targetClosure(self)
    }

    public func addAction(for event: UIControl.Event, closure: @escaping UIControlTargetClosure) {
        targetClosure = closure
        addTarget(self, action: #selector(UIControl.closureAction), for: event)
    }

}

UIViewController의 extension

extension UIViewController {
    func showPopUp(title: String? = nil,
                   message: String? = nil,
                   attributedMessage: NSAttributedString? = nil,
                   leftActionTitle: String = "취소",
                   rightActionTitle: String = "확인",
                   leftActionCompletion: (() -> Void)? = nil,
                   rightActionCompletion: (() -> Void)? = nil) {
        let popUpViewController = PopUpViewController(titleText: title,
                                                      messageText: message,
                                                      attributedMessageText: attributedMessage)
        showPopUp(popUpViewController: popUpViewController,
                  leftActionTitle: leftActionTitle,
                  rightActionTitle: rightActionTitle,
                  leftActionCompletion: leftActionCompletion,
                  rightActionCompletion: rightActionCompletion)
    }

    func showPopUp(contentView: UIView,
                   leftActionTitle: String = "취소",
                   rightActionTitle: String = "확인",
                   leftActionCompletion: (() -> Void)? = nil,
                   rightActionCompletion: (() -> Void)? = nil) {
        let popUpViewController = PopUpViewController(contentView: contentView)

        showPopUp(popUpViewController: popUpViewController,
                  leftActionTitle: leftActionTitle,
                  rightActionTitle: rightActionTitle,
                  leftActionCompletion: leftActionCompletion,
                  rightActionCompletion: rightActionCompletion)
    }

    private func showPopUp(popUpViewController: PopUpViewController,
                           leftActionTitle: String,
                           rightActionTitle: String,
                           leftActionCompletion: (() -> Void)?,
                           rightActionCompletion: (() -> Void)?) {
        popUpViewController.addActionToButton(title: leftActionTitle,
                                              titleColor: .systemGray,
                                              backgroundColor: .secondarySystemBackground) {
            popUpViewController.dismiss(animated: false, completion: leftActionCompletion)
        }

        popUpViewController.addActionToButton(title: rightActionTitle,
                                              titleColor: .white,
                                              backgroundColor: .blue) {
            popUpViewController.dismiss(animated: false, completion: rightActionCompletion)
        }
        present(popUpViewController, animated: false, completion: nil)
    }
}

사용하는 쪽에서의 호출

class ViewController: UIViewController {

    @IBOutlet weak var button: UIButton!
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

    @IBAction func didTapButton(_ sender: Any) {
        showPopUp(title: "타이틀", message: "메세지")
    }

}

* 전체 코드: https://github.com/JK0369/PopUpViewControllerExample

Comments