UX는 무엇이 결정 하는가 [1부]에서는 왜 일관된 UX가 중요한지에 대해서 이야기를 했다. 이번 글에서는 실재 디자인적 일관성을 개발로 어떻게 구현했는지 설명하겠다.
class CommonModal : BaseViewController {
let disposeBag = DisposeBag()
let blurView = UIView().then {
$0.backgroundColor = .black.withAlphaComponent(0.3)
}
let parentView = UIView().then {
$0.backgroundColor = .clear
$0.layer.cornerRadius = 16
}
let titleLabel = UILabel().then {
$0.textAlignment = .center
$0.numberOfLines = 1
}
let imageView = UIImageView()
let messageLabel = UILabel().then {
$0.textAlignment = .center
$0.numberOfLines = 0
}
let underLineView = UIView().then {
$0.heightAnchor.constraint(equalToConstant: 1).isActive = true
}
let nagativeButton = UILabel().then {
$0.textAlignment = .center
}
var nagativeDelegate : (CommonModal) -> Void = { _ in }
let positiveButton = UILabel().then {
$0.textAlignment = .center
}
var positiveDelegate : (CommonModal) -> Void = { _ in }
lazy var buttonStackView = UIStackView(arrangedSubviews: [nagativeButton, positiveButton]).then {
$0.heightAnchor.constraint(equalToConstant: 44).isActive = true
$0.distribution = .fillEqually
$0.axis = .horizontal
}
let pillarLineView = UIView().then {
$0.widthAnchor.constraint(equalToConstant: 1).isActive = true
}
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
layout()
bind()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/*
화연에 표시
*/
func show() {
/*
현재 화면의 최상단에 보이는 ViewController가 자기 자신인지 판단.
*/
if topMostViewController !== self {
/*
최상단 ViewController를 통해서 Modal 보여주기
*/
self.view.backgroundColor = .clear
self.modalTransitionStyle = .crossDissolve
self.modalPresentationStyle = .overFullScreen
topMostViewController?.present(self, animated: true)
}
}
func bind() {
nagativeButton.rx.tapGesture()
.when(.recognized)
.bind(onNext : { [weak self] _ in
guard let self = self else { return }
self.nagativeDelegate(self)
})
.disposed(by: disposeBag)
positiveButton.rx.tapGesture()
.when(.recognized)
.bind(onNext : { [weak self] _ in
guard let self = self else { return }
self.positiveDelegate(self)
})
.disposed(by: disposeBag)
}
private func configure(
title : String,
message : String,
image : UIImage?,
imageUrl : String?,
imgWidth : Int,
imgHeight : Int,
nagativeButtonStr : String,
nagativeButtonDelegate : @escaping (CommonModal) -> Void,
positiveButtonStr : String,
positiveButtonDelegate : @escaping (CommonModal) -> Void,
/*
속성
*/
titleColor : UIColor,
messageColor : UIColor,
nagativeButtonColor : UIColor,
positiveButtonColor : UIColor,
modalBackgroundColor : UIColor,
lineColor : UIColor
) {
self.titleLabel.text = title
self.messageLabel.text = message
/*
image
*/
imageView.snp.makeConstraints {
$0.width.equalTo(imgWidth)
$0.height.equalTo(imgHeight)
}
if let image = image {
imageView.image = image
imageView.snp.makeConstraints {
$0.top.equalTo(titleLabel.snp.bottom).offset(16)
}
}
if let imageUrl = imageUrl {
imageView.kf.setImage(with: URL(string: imageUrl))
imageView.snp.makeConstraints {
$0.top.equalTo(titleLabel.snp.bottom).offset(16)
}
}
/*
setNavigationButton을 안한다면 숨겨서 positiveButton만 보이게 동작하자...
*/
self.nagativeButton.isHidden = nagativeButtonStr.isEmpty
self.pillarLineView.isHidden = nagativeButtonStr.isEmpty
self.nagativeButton.text = nagativeButtonStr
self.nagativeDelegate = nagativeButtonDelegate
self.positiveButton.text = positiveButtonStr
self.positiveDelegate = positiveButtonDelegate
/*
속성
*/
self.titleLabel.textColor = titleColor
self.messageLabel.textColor = messageColor
self.nagativeButton.textColor = nagativeButtonColor
self.positiveButton.textColor = positiveButtonColor
self.parentView.backgroundColor = modalBackgroundColor
self.underLineView.backgroundColor = lineColor
self.pillarLineView.backgroundColor = lineColor
}
/*
자신의 Design에 맞게 layout을 배치
정확히는 설정을 했을때 그릇이 되는 View들에 대해서
각 상황에 대응하는 layout을 배치하는 것이 핵심
*/
func layout() {
[
blurView,
parentView,
titleLabel,
imageView,
messageLabel,
underLineView,
buttonStackView,
pillarLineView,
].forEach {
view.addSubview($0)
}
blurView.snp.makeConstraints {
$0.edges.equalToSuperview()
}
parentView.snp.makeConstraints {
$0.width.equalTo(270)
$0.center.equalToSuperview()
$0.top.equalTo(titleLabel).offset(-16)
$0.bottom.equalTo(buttonStackView)
}
titleLabel.snp.makeConstraints {
$0.top.equalTo(parentView)
$0.centerX.equalTo(parentView).inset(16)
}
imageView.snp.makeConstraints {
$0.top.equalTo(titleLabel.snp.bottom)
$0.centerX.equalTo(parentView)
}
messageLabel.snp.makeConstraints {
$0.top.equalTo(imageView.snp.bottom).offset(16)
$0.leading.trailing.equalTo(parentView).inset(16)
}
underLineView.snp.makeConstraints {
$0.top.equalTo(messageLabel.snp.bottom).offset(16)
$0.leading.trailing.equalTo(parentView)
}
buttonStackView.snp.makeConstraints {
$0.top.equalTo(underLineView.snp.bottom)
$0.leading.trailing.equalTo(parentView)
$0.bottom.equalTo(parentView)
}
pillarLineView.snp.makeConstraints {
$0.top.bottom.equalTo(buttonStackView)
$0.centerX.equalTo(buttonStackView)
}
}
class Builder {
/*
...
이후 필요한 속성에 대해서 지속적으로 추가하면 된다
ex) Title, Message, button Font
*/
/*
Default 값 설정
*/
private var title : String = ""
private var message : String = ""
private var image : UIImage? = nil
private var imageUrl : String? = nil
private var imgWidt : Int = 0
private var imghight : Int = 0
private var nagativeButtonStr : String = ""
private var nagativeButtonDelegate : (CommonModal) -> Void = { _ in }
private var positiveButtonStr : String = ""
private var positiveButtonDelegate : (CommonModal) -> Void = { _ in }
/*
color 속성
Default 값 설정
*/
private var titleColor : UIColor = .black
private var messageColor : UIColor = .black
private var nagativeButtonColor : UIColor = .blue
private var positiveButtonColor : UIColor = .red
private var modalBackgroindColor : UIColor = .white
private var lineColor : UIColor = .gray
func setTitle(_ title : String) -> Self {
self.title = title
return self
}
func setMessage(_ message : String) -> Self {
self.message = message
return self
}
func setImage(_ image : UIImage?, width : Int, height : Int) -> Self {
self.image = image
self.imgWidt = width
self.imghight = height
return self
}
func setImageUrl(_ url : String?, width : Int, height : Int) -> Self {
self.imageUrl = url
self.imgWidt = width
self.imghight = height
return self
}
func setNagativeButton(
_ label : String,
_ delegate : @escaping (CommonModal) -> Void
) -> Self {
self.nagativeButtonStr = label
self.nagativeButtonDelegate = delegate
return self
}
func setPositiveButton(
_ label : String,
_ delegate : @escaping (CommonModal) -> Void
) -> Self {
self.positiveButtonStr = label
self.positiveButtonDelegate = delegate
return self
}
/*
color 속성
*/
func setTitleColor(_ color : UIColor) -> Self {
self.titleColor = color
return self
}
func messageColor(_ color : UIColor) -> Self {
self.messageColor = color
return self
}
func setNagativeButtonColor(_ color : UIColor) -> Self {
self.nagativeButtonColor = color
return self
}
func setPositiveButtonColor(_ color : UIColor) -> Self {
self.positiveButtonColor = color
return self
}
func setModalBackgroundColor(_ color : UIColor) -> Self {
self.modalBackgroindColor = color
return self
}
func setLineColor(_ color : UIColor) -> Self {
self.lineColor = color
return self
}
func build() -> CommonModal {
return CommonModal().then {
$0.configure(
title: title,
message: message,
image: image,
imageUrl: imageUrl,
imgWidth: imgWidt,
imgHeight: imghight,
nagativeButtonStr: nagativeButtonStr,
nagativeButtonDelegate: nagativeButtonDelegate,
positiveButtonStr: positiveButtonStr,
positiveButtonDelegate: positiveButtonDelegate,
titleColor: titleColor,
messageColor: messageColor,
nagativeButtonColor: nagativeButtonColor,
positiveButtonColor: positiveButtonColor,
modalBackgroundColor: modalBackgroindColor,
lineColor: lineColor
)
}
}
}
}
Builder 클래스는 데이터 세팅 및 CommonModal의 최종 생산(build) 역할을 담당한다. 그리고 CommonModal는 이 데이터를 담는 그릇배치 및 표현(show)의 역할을 담당한다.
[자세한 설명은 주석 참고]
struct CommoBottomModalAction {
let title : String
let titleColor : UIColor
let action : (CommonBottomModal) -> Void
}
class CommonBottomModal : BaseViewController {
let disposeBag = DisposeBag()
let blurView = UIView().then {
$0.backgroundColor = .clear
}
var actions : [CommoBottomModalAction] = []
let stackView = UIStackView().then {
$0.spacing = 5
$0.axis = .vertical
}
let cancleButton = UILabel().then {
$0.heightAnchor.constraint(equalToConstant: 56).isActive = true
$0.backgroundColor = .clear
$0.clipsToBounds = true
$0.layer.cornerRadius = 16
$0.textAlignment = .center
}
func show() {
/*
최상단 ViewController가 자기 자신인지 판단
*/
if topMostViewController !== self {
topMostViewController?.presentPanModal(self)
}
}
private func configure(
_ actions : [CommoBottomModalAction],
_ actionBackgroundColor : UIColor,
_ cancelMessage : String,
_ cancelBackgroundColor : UIColor
) {
/*
actionData Label로 변환
실질 적인 UI/UX를 하는 실재로 변환하는 과정
*/
let labels = actions
.map { action in
UILabel().then {
$0.text = action.title
$0.textColor = action.titleColor
$0.heightAnchor.constraint(equalToConstant: 56).isActive = true
$0.backgroundColor = actionBackgroundColor
$0.clipsToBounds = true
$0.layer.cornerRadius = 16
$0.textAlignment = .center
}
}
cancleButton.text = cancelMessage
cancleButton.backgroundColor = cancelBackgroundColor
/*
stackView에 추가
*/
labels.forEach {
stackView.addArrangedSubview($0)
}
stackView.addArrangedSubview(cancleButton)
[
blurView,
stackView
].forEach {
view.addSubview($0)
}
blurView.snp.makeConstraints {
$0.edges.equalToSuperview()
}
/*
actions의 아이템 개수에 따라 stackView의 크기를 동적으로 결정
*/
stackView.snp.makeConstraints {
$0.leading.trailing.equalToSuperview().inset(16)
$0.bottom.equalToSuperview().inset(38)
/*
주요 코드
*/
$0.top.equalTo(labels[0])
}
/*
bind
*/
blurView.rx.tapGesture()
.when(.recognized)
.bind(onNext : { [weak self] _ in
self?.dismiss(animated: true)
})
.disposed(by: disposeBag)
cancleButton.rx.tapGesture()
.when(.recognized)
.bind(onNext : { [weak self] _ in
self?.dismiss(animated: true)
})
.disposed(by: disposeBag)
/*
commonActionDelegate에서 정의한 함수 호출
*/
labels
.enumerated()
.forEach { index, label in
label.rx.tapGesture()
.when(.recognized)
.bind(onNext : { [weak self] _ in
guard let self = self else { return }
self.dismiss(animated: true) {
actions[index].action(self)
}
})
.disposed(by: disposeBag)
}
}
class Builder {
/*
필요한 Design에 맞게 속성 추가
*/
private var actions : [CommoBottomModalAction] = []
private var actionBackground : UIColor = .white
private var cancelMessage : String = "취소"
private var cancelBackgroundColor : UIColor = .gray
func setActions(_ actions : [CommoBottomModalAction]) -> Self {
self.actions = actions
return self
}
func setActionBackgroundColor(_ color : UIColor) -> Self {
self.actionBackground = color
return self
}
func setCancelMessage(_ message : String) -> Self {
self.cancelMessage = message
return self
}
func cancelBackgroundColor(_ color : UIColor) -> Self {
self.cancelBackgroundColor = color
return self
}
/*
설정을 모두 완료하고 이때 CommonBottomModal 생성
*/
func build() -> CommonBottomModal {
return CommonBottomModal().then {
$0.configure(
actions,
actionBackground,
cancelMessage,
cancelBackgroundColor
)
}
}
}
}
extension CommonBottomModal : PanModalPresentable {
var showDragIndicator: Bool {
return false
}
var panScrollable: UIScrollView? {
return nil
}
var shortFormHeight: PanModalHeight {
return .maxHeightWithTopInset(0)
}
var longFormHeight: PanModalHeight {
return .maxHeightWithTopInset(0)
}
var anchorModalToLongForm: Bool {
return false
}
}
기본 원리는 CommonModal과 같다. 다른점이 있다면 BottomModal의 경우에 Action의 개수가 다양 할 수있다. 예제 에서는 2개의 액션만 보여지지만 5개가 될 수 도 있다. 이 액션에 대해서 각각의 동작을 정의해 줘야 했기에 CommoBottomModalAction를 구현했고 이를 통해서 각각의 액션을 정의해 Builder에게 전달하는 식으로 개발을 했다.
[자세한 설명은 주석 참고]
class CommonToast : UIView {
private let disposeBag = DisposeBag()
private var onClickDelegate : (CommonToast) -> Void = { _ in }
private let messageLabel = UILabel().then {
$0.textAlignment = .center
$0.numberOfLines = 0
}
override init(frame: CGRect) {
super.init(frame: frame)
layout()
bind()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
let topMostViewController = (UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate)?.window?.rootViewController?.topMostViewController
topMostViewController?.view.showToast(self, duration: 1, position: .bottom)
}
private func bind() {
/*
ToastButton을 탭 했을때
*/
self.rx.tapGesture()
.when(.recognized)
.bind(onNext : { [weak self] _ in
guard let self = self else { return }
self.onClickDelegate(self)
})
.disposed(by: disposeBag)
}
private func configure(
message : String,
messageColor : UIColor,
backgroundColor : UIColor,
onClickDelegate : @escaping (CommonToast) -> Void
) {
self.messageLabel.text = message
self.messageLabel.textColor = messageColor
self.backgroundColor = backgroundColor
self.onClickDelegate = onClickDelegate
}
private func layout() {
[
messageLabel
].forEach {
addSubview($0)
}
messageLabel.snp.makeConstraints {
$0.center.equalToSuperview()
}
}
/*
필요에 의해서 추가 속성 등록 및 사용
*/
class Builder {
private var message : String = ""
private var messageColor : UIColor = .black
private var backgroundColor : UIColor = .cyan
private var heightSize : CGFloat = 56
private(set) var onClickDelegate : (CommonToast) -> Void = { _ in }
func setMessage(_ message : String) -> Self {
self.message = message
return self
}
func setMessageColor(_ color : UIColor) -> Self {
self.messageColor = color
return self
}
func setBackgroundColor(_ color : UIColor) -> Self {
self.backgroundColor = color
return self
}
func setHeightSize(_ height : CGFloat) -> Self {
self.heightSize = height
return self
}
func setOnClickDelegate(_ delegate : @escaping (CommonToast) -> Void) -> Self {
self.onClickDelegate = delegate
return self
}
func build() -> CommonToast {
let rootViewController = (UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate)?.window?.rootViewController
return CommonToast(frame: .init(x: 0, y: 0, width: rootViewController?.view.frame.width ?? 0, height: heightSize)).then {
$0.configure(
message: message,
messageColor: messageColor,
backgroundColor: backgroundColor,
onClickDelegate: onClickDelegate
)
}
}
}
}
원리는 CommoModal과 같다.
/*
Lottie를 통한 LoadingView
*/
class CommonLoadingView {
var loadingView : LottieAnimationView? = LottieAnimationView(name: "lottie").then {
$0.isHidden = true
$0.loopMode = .loop
$0.play()
}
var timer : Timer? = nil
var timeRemaining = 0.8
convenience init() {
let superView = (UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate)?.window?.rootViewController?.topMostViewController?.view
self.init(superView: superView)
}
init(superView : UIView?) {
superView?.addSubview(loadingView!)
loadingView?.snp.makeConstraints {
$0.center.equalToSuperview()
}
}
func show() {
/*
touch 비활성
*/
let window = (UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate)?.window
window?.isUserInteractionEnabled = false
/*
타이머가 동작하기 전에는 보여줘서는 안되기 때문에
loadingView를 숨긴다.
*/
loadingView?.isHidden = true
startTimer()
}
func dismiss() {
hide()
stopTimer()
loadingView?.removeFromSuperview()
loadingView = nil
}
private func hide() {
/*
touch 활성
*/
let window = (UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate)?.window
window?.isUserInteractionEnabled = true
self.loadingView?.isHidden = true
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: timeRemaining, repeats: true) { [weak self] _ in
guard let self = self else { return }
self.timeRemaining -= 0.8
/*
0.8초가 지나면 호출
*/
if self.timeRemaining == 0 {
/*
loadingView zIndex 최고 레벨 부여
*/
self.loadingView?.layer.zPosition = CGFloat(Float.greatestFiniteMagnitude)
self.loadingView?.isHidden = false
}
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
}
LoadingView는 Lottie-ios를 사용해서 표현했다. 내가 어디서 본지는 기억이 안나지만 사용자가 화면에서 집중력을 잃는 시간이 0.8초라고 들은적이 있다. 나는 이 근거로 화면에 LoadingView를 표시(show)할때 0.8초 전에는 LoadingView를 표시하지 않고 0.8초가 지나면 LoadingView를 표시하도록 로직을 작성해 봤다.
또한 어떤 작업이 진행될때 사용자가 추가 동작을 하면 못하도록 화면의 Touch또한 막아줬다.(이 화면터치는 0.8초와 상관없이 dismiss가 호출될때까지 이루어 진다.)
그리고 어떤 특정 작업(ex) Network)이 완료되면 LoadingView를 화면에서 지운도 그리고 터치도 다시 활성화 한다.(dismiss) 이번 예제에서는 Network 상황은 구현하지 않았고 대신에 timer를 통해 동작이 어떻게 이뤄지는지 구현했다.
[자세한 내용은 주석 참고]
class DetailViewController : BaseViewController {
let disposeBag = DisposeBag()
let commonRetry = UILabel().then {
$0.text = "CommonRetry"
}
var loadingView : CommonLoadingView? = nil
var timeRemaining = 2.0
var timer : Timer? = nil
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
layout()
bind()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
...
/*
BaseViewController retry 함수 오버라이드
*/
override func retry() {
super.retry()
print("HELLO RETRY")
}
...
}
class CommonRetryView : UIView {
static let EXIST = 1
let disposeBag = DisposeBag()
let titleLabel = UILabel().then {
$0.numberOfLines = 0
}
let retryButton = UILabel().then {
$0.textColor = .systemRed
$0.numberOfLines = 0
}
override var intrinsicContentSize: CGSize {
let titleSize = titleLabel.intrinsicContentSize
let buttonSize = retryButton.intrinsicContentSize
let width = min(titleSize.width, buttonSize.width)
let height = titleSize.height + 16 + buttonSize.height
return CGSize(width: width, height: height)
}
override init(frame: CGRect) {
super.init(frame: frame)
layout()
bind()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
let topMostViewController = (UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate)?.window?.rootViewController?.topMostViewController
let alreadyAdded = topMostViewController?.view.subviews.contains(where: { $0.tag == CommonRetryView.EXIST }) ?? false
guard !alreadyAdded else { return }
topMostViewController?.view.addSubview(self)
self.snp.makeConstraints {
$0.center.equalToSuperview()
}
}
func bind() {
retryButton.rx.tapGesture()
.when(.recognized)
.bind(onNext : { [weak self] _ in
let topMostViewController = (UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate)?.window?.rootViewController?.depthViewController
let baseViewController = topMostViewController as? BaseViewController
/*
BaseViewController에서 Override한 retry함수 호출
*/
baseViewController?.retry()
self?.removeFromSuperview()
})
.disposed(by: disposeBag)
}
private func configure(
_ title : String,
_ buttonText : String
) {
titleLabel.text = title
retryButton.text = buttonText
/*
tag 설정
*/
self.tag = CommonRetryView.EXIST
/*
frame 설정
*/
self.frame = CGRect(origin: .zero, size: intrinsicContentSize)
}
func layout() {
[
titleLabel,
retryButton
].forEach {
addSubview($0)
}
titleLabel.snp.makeConstraints {
$0.top.equalToSuperview()
$0.centerX.equalToSuperview()
}
retryButton.snp.makeConstraints {
$0.top.equalTo(titleLabel.snp.bottom).offset(16)
$0.centerX.equalTo(titleLabel)
}
}
class Builder {
private var title : String = ""
private var retryStr : String = ""
func setTitle(_ title : String) -> Self {
self.title = title
return self
}
func setRetryStr(_ retryStr : String) -> Self {
self.retryStr = retryStr
return self
}
func build() -> CommonRetryView {
CommonRetryView().then {
$0.configure(
title,
retryStr
)
}
}
}
}
CommonRetryView에서 재시도 버튼을 누르는 코드를 보면 현재 화면에 보이는 가장 자녀 ViewController를 호출한다. 이 ViewController는 BaseViewController를 상속 받은 ViewController일 것이다. 왜냐 하면 내가 그렇게 설계를 했으니까! 그리고 이 ViewController의 retry를 실행한다. 즉 retry 함수를 override를 하여 각 화면에 맞는 retry 로직을 작성하면 된다. 이렇게 하면 재시도에 대한 원하는 동작을 구현 할 수 있다!!
위와 같이 개발했을때 디자인의 일관성을 구현했는가? 구현했다고 할 수 있다. 이유는 내가 만약 Loading을 표현해야 되고 Modal을 표현 한다고 하면 위의 Component들을 사용할것이기 때문이다. 그리고 이 Component들은 각자의 일관된 설계를 가지고 있기때문에 이렇게 해서 나는 일관된 UI/UX를 구현했다. 한단계 더 나아가 나는 Builder에 속성을 추가만 하면 새롭게 속성을 설정할 수 있도록 확장성 있는코드도 작성해 봤다. 전체코드
네이티브 개발자는 최전선에서 사용자와 상호작용 하는 개발자이다.
개발자로서 여러 덕목도 있겠지만 좋은 UI/UX를 어떻게 사용자에게 전달해야 되는가 또한 매우 중요한 덕목이겠다. 그런 고민이 있기에 더 좋은 개발이 나올것이라고 믿는다. 앞으로도 나는 사용자에게 좋은 UI/UX를 제공하기 위해서는 어떻게 해야 되는가에대해서 고민하고 개발적으로 풀어나가는 노력을 열심히 할것이다.