본문 바로가기

MOBILE/ios

[iOS] side bar 라이브러리 없이 직접 구현하기

앱에서 정보를 숨기지만 접근하기 쉽게 만드는 UI는 무엇일까?

바로 side bar이다. 정보를 어느 정도 숨겨주지만 side bar를 터치하여 누르면 다양한 정보와 기능이 나타나도록 구현할 수 있어서 자주 사용된다. 이러한 side bar는 제공해주는 UI 요소가 아니기 때문에 직접 구현해야 한다.

다행히 많은 라이브러리들이 있지만 직접 sidebar 를 구현해보도록 하자.


Custom Container View Controller

 

container view controller는 다른 view controller와 달리 실질적인 화면, 자식 뷰 컨트롤러를 관리하는 역할만 한다.

예를 들어 tab bar controller, navigation view controller, page view controller, split view controller 가 이에 해당한다.

즉 여러개의 뷰 컨트롤러를 포함하고 관리하는 데에 사용되는 것이다. 실질적인 화면 표시는 자식 뷰 컨트롤러가 하게 된다.

 

side bar는 이러한 뷰 컨트롤러를 관리하는 커스텀 컨테이너 뷰 컨트롤러가 필요하다.

1. 프론트 컨트롤러에서 뷰를 가져와서 메인 화면에 보여준다.

2. 사이드 바 컨트롤러에서 뷰를 가져와서 사이드 바를 열었을 때 화면을 보여준다.

 

컨테이너 뷰 컨트롤러는 대부분 UIViewController 를 서브 클래싱 하여 사용된다.

따라서 여기서도 UIViewController를 서브클래싱한다.

 

class SideBarViewController: UIViewController {

    var frontVC: UIViewController?
    var sideVC: UIViewController?

    var isOpen = false // side bar가 열렸는지 닫혔는지 확인
}

 

side bar를 구현하기 위해서는 두개의 뷰가 필요하다.

다음과 같은 화면을 만들 것이다.

 

side bar를 열기 전의 화면이 frontVC가 되고, side bar를 열면 나오는 화면이 sideVC이다.

이를 구현하면 다음과 같다.

 

 

그럼 이제 커스텀 컨테이너 클래스 코드를 작성해보자

먼저 View가 로드되고 나서 자식 뷰 컨트롤러를 자식 뷰로 추가하는 코드는 다음과 같다.

 

override func viewDidLoad() {
    super.viewDidLoad()
    
    if let vc = self.storyboard?.instantiateViewController(withIdentifier: "sideBar") as? UIViewController {
        self.frontVC = vc
        self.addChild(vc)
        self.view.addSubview(vc.view);
        vc.didMove(toParent: self)
    }
}

 

front view controller를 읽어와서 컨테이너 컨트롤러의 자식 뷰 컨트롤러로 연결한다. 

이때 코코아 터치 프레임워크에서는 뷰와 컨트롤러 계층은 분리되어 있다. 즉 컨트롤러를 자식 컨트롤러로 추가했다고 하더라도

뷰는 자식 뷰로 자동으로 추가되지 않는다는 뜻이다. 따라서 뷰 컨트롤러와 뷰를 각각 등록해야 한다.

addChild() 메소드로는 자식 뷰 컨트롤러를 추가하고 addSubview() 메소드로는 자식 뷰를 추가한다.

그 후에 didMove() 메소드로 자식 뷰 컨트롤러에게 부모 뷰가 바뀌었다고 알려준다.

 

side bar의 뷰를 가져오는 코드는 메소드로 작성한다.

왜냐면 side bar는 사용자가 여는 때에만 나타나야 하기 때문이다. 즉 항상 컨트롤러 객체가 유지될 필요가 없고, 유지되는 것도 낭비이다.

따라서 메소드로 구현하여 컨트롤러 인스턴스를 메모리에 올렸다가 side bar가 닫히면 다시 메모리에서 인스턴스를 제거하는 코드를 작성하기 위해서 메소드로 분리해서 구현한다.

 

    func getSideBar()
    {
        guard self.sideVC == nil else {
            return
        }
        
        if let vc = self.storyboard?.instantiateViewController(withIdentifier: "frontView") as? UIViewController {
            self.addChild(vc)
            self.view.addSubview(vc.view);
            vc.didMove(toParent: self)
            self.view.bringSubviewToFront((self.frontVC?.view)!)
            
            self.frontVC?.view.layer.masksToBounds = false
            self.frontVC?.view.layer.cornerRadius = 10
            self.frontVC?.view.layer.shadowOpacity = 0.8
            self.frontVC?.view.layer.shadowColor = UIColor.black.cgColor
            self.frontVC?.view.layer.shadowOffset = CGSize(width: -2, height: -2)
        }
    }

 

그 다음사이드 바가 맨 앞으로 올 수 있도록 bringSubviewToFront() 메소드를 사용하여 뷰를 최상단으로 가져온다.

 

side bar를 열게 되면 열었다는 느낌을 사용자에게 주어야 한다. 그림자를 통해서 이러한 느낌을 구현할 수 있다.

이를 위한 화면 효과들을 설정한다.

shadowOpacity: 그림자 투명도

cornerRadius: 그림자 모서리 둥글게

shadowColor: 그림자 색상

shadowOffset: 그림자 크기


open, close

 

side bar를 여는 함수는 다음과 같다.

 

    func openSidebar(_ complete: (() -> Void)?){
        self.getSideBar()
        
        let options = UIView.AnimationOptions([.curveEaseInOut, .beginFromCurrentState])
        
        UIView.animate(withDuration: TimeInterval(0.3), delay: TimeInterval(0), options: options, animations: {
            self.frontVC?.view.frame = CGRect(x: 260, y: 0, width: self.view.frame.width, height: self.view.frame.height)
        }, completion: {
            if $0 == true {
                self.isOpen = true
                complete?()
            }
        })
    }

 

side bar를 열때에도 마찬가지로 열린다는 느낌을 주기 위해서 애니메이션 효과를 뷰에 적용한다.

뷰에 애니메이션을 적용하는 메소드는 다음과 같다.

 

 

첫 번째 파라미터, duration

애니메이션 실행 시간이다. 단위는 '초'이다.

타입의 호환을 위해 TimeInterval 객체를 사용하는 것이 권장이다.

 

두 번째 파라미터, delay

애니메이션 실행 전에 대기하는 시간이다. 즉 약간의 지연 시간을 주고 싶은 경우 사용 가능하며, 단위는 '초'이다.

TimeInterval 객체를 사용한다. (ex. TimerInterval(0))

 

세 번째 파라미터, options

애니메이션의 실행 옵션이다. 즉 애니메이션을 어떻게 실행할지와 관련된 화면 효과 옵션이다.

 

네 번째 파라미터, animations

애니메이션의 실행 내용이다. 즉 어떤 것을 실행할지에 관한 것이다.

클로저나 함수 형태로 사용한다. 이때에는 애니메이션이 완료되고 실행하고 보여주고 싶은 것을 작성한다.

 

다섯 번째 파라미터, completion

애니메이션 완료 후 실행해야 할 내용

애니메이션은 비동기로 실행된다. 따라서 완료 시점을 특정하기 어렵기 때문에 완료 후 실행해야 하는 구문은 클로저로 작성하여 넘겨준다.

completion 매개변수에 입력된 함수나 클로저는 애니메이션 호출 후 실행되도록 시스템이 보장하기 때문이다.

 

 

 

side bar를 닫는 함수는 다음과 같다.

 

    func closeSidebar(_ complete: (() -> Void)?) {
        let options = UIView.AnimationOptions([.curveEaseInOut, .beginFromCurrentState])
        
        UIView.animate(withDuration: TimeInterval(0.3), delay: TimeInterval(0), options: options, animations: {
            self.frontVC?.view.frame = CGRect(x: 0, y: 0, width: self.view.frame.width, height: self.view.frame.height)
        }, completion: {
            if $0 == true {
                self.sideVC?.view.removeFromSuperview()
                self.sideVC = nil
                
                self.frontVC?.view.layer.cornerRadius = 0.0
                self.frontVC?.view.layer.shadowOffset = CGSize(width: 0, height: 0)
                
                self.isOpen = false
                complete?()
            }
        })
        
    }

 

열때 적용했던 애니메이션을 닫을 때는 반대로 적용하면 된다.

side bar가 닫히고 나면 완료 클로저에는 그림자 효과를 해제하는 코드와 side bar view를 뷰 컨트롤러 객체에서 제거한다.

removeFromSuperview() 메소드를 사용 가능하다.

이는 메모리 낭비를 막기 위함이다.


Delegate Pattern

 

이제 front view controller에서 side bar를 열기 위한 버튼을 만들고, 버튼을 눌렀을 때 작성해둔 컨테이너 뷰 컨트롤러가 작동되도록 하자. 이를 위해서는 delegate pattern이 필요하다.

즉 front view가 side bar 에 대한 동작을 처리하는 것이 아니라 작성해둔 컨테이너 뷰 컨트롤러가 동작하도록 하는 것이다.

 

버튼을 넣고 이를 액션 메소드로 연결한다.

 

코드는 다음과 같다.

 

class FrontViewController: UIViewController {

    var delegate: ViewController?
    
    @IBAction func doSidebar(_ sender: Any) {
        if self.delegate?.isOpen == false {
            self.delegate?.openSidebar(nil)
        }
        else {
            self.delegate?.closeSidebar(nil)
        }
    }
    
}

 

이때 delegate 변수는 바로 컨테이너 뷰 컨트롤러와 이어진다. 따라서 delegate 변수에 들어간 ViewController클래스가 버튼에 대한 동작을 처리하는 것이다.

하지만 아직까지 delegate 변수의 값은 비어있으므로 nil 값이다. 그렇다면 어디서 넣어줘야 하는가?

정답은 컨테이너 뷰 컨트롤러에서 처리하는 것이다.

 

 // 컨테이너 뷰 컨트롤러 - ViewController
 
   override func viewDidLoad() {
        super.viewDidLoad()
        
        if let vc = self.storyboard?.instantiateViewController(withIdentifier: "frontView") as? UIViewController {
            
            self.frontVC = vc
            self.addChild(vc)
            self.view.addSubview(vc.view);
            vc.didMove(toParent: self)
            
            (vc as! FrontViewController).delegate = self
        }
    }

 

앞에서 컨테이너 뷰 컨트롤러의 viewDidLoad() 메소드에 delegate를 설정해주는 코드를 추가하면 완성이다.


완성