Skip to content

Commit b2c27f2

Browse files
committed
[BOOK-365] feat: add NotificationSettings
1 parent a21add6 commit b2c27f2

File tree

4 files changed

+466
-0
lines changed

4 files changed

+466
-0
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright © 2025 Booket. All rights reserved
2+
3+
import UIKit
4+
5+
final class NotificationSettingsCoordinator: Coordinator {
6+
var parentCoordinator: Coordinator?
7+
var childCoordinators = [Coordinator]()
8+
var navigationController: UINavigationController
9+
10+
init(
11+
parentCoordinator: Coordinator?,
12+
navigationController: UINavigationController
13+
) {
14+
self.parentCoordinator = parentCoordinator
15+
self.navigationController = navigationController
16+
}
17+
18+
func start() {
19+
let notificationViewController = NotificationSettingsViewController(
20+
viewModel: NotificationSettingsViewModel()
21+
)
22+
notificationViewController.coordinator = self
23+
navigationController.pushViewController(notificationViewController, animated: true)
24+
}
25+
}
26+
27+
extension NotificationSettingsCoordinator: URLPresenting {}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright © 2025 Booket. All rights reserved
2+
3+
import BKDesign
4+
import UIKit
5+
import SnapKit
6+
7+
final class NotificationSettingsView: BaseView {
8+
// MARK: - Closures
9+
var onNotificationToggleChanged: ((Bool) -> Void)?
10+
var onPermissionRequestViewTapped: (() -> Void)?
11+
12+
// MARK: - UI Components
13+
private let scrollView: UIScrollView = {
14+
let scrollView = UIScrollView()
15+
scrollView.alwaysBounceVertical = true
16+
scrollView.showsVerticalScrollIndicator = false
17+
return scrollView
18+
}()
19+
20+
private let contentView = UIView()
21+
22+
private let permissionRequestContainer = UIView()
23+
private let permissionRequestView = UIView()
24+
private let permissionRequestLabelStack: UIStackView = {
25+
let stackView = UIStackView()
26+
stackView.axis = .vertical
27+
stackView.spacing = LayoutConstants.permissionRequestStackViewSpacing
28+
stackView.alignment = .leading
29+
return stackView
30+
}()
31+
32+
private let permissionRequestTitle = BKLabel(
33+
text: "알림을 켜주세요.",
34+
fontStyle: .body1(weight: .semiBold),
35+
color: .bkContentColor(.brand)
36+
)
37+
38+
private let permissionRequestContent = BKLabel(
39+
text: """
40+
기기 설정에서 Reed 알림을 설정하세요.
41+
독서 기록에 도움되는 알림을 받을 수 있어요.
42+
""",
43+
fontStyle: .label2(weight: .regular),
44+
color: .bkContentColor(.tertiary)
45+
)
46+
47+
private let permissionRequestButton: UIImageView = {
48+
let imageView = UIImageView(image: BKImage.Icon.chevronRight)
49+
imageView.tintColor = .bkContentColor(.brand)
50+
return imageView
51+
}()
52+
53+
private let permissionToggleContainer = UIView()
54+
private let permissionToggleLabelStack: UIStackView = {
55+
let stackView = UIStackView()
56+
stackView.axis = .vertical
57+
stackView.spacing = .zero
58+
stackView.alignment = .leading
59+
return stackView
60+
}()
61+
62+
private let permissionToggleTitle = BKLabel(
63+
text: "알림 받기",
64+
fontStyle: .body1(weight: .medium),
65+
color: .bkContentColor(.primary)
66+
)
67+
68+
private let permissionToggleContent = BKLabel(
69+
text: "리드에서 알림을 보내드려요.",
70+
fontStyle: .label1(weight: .regular),
71+
color: .bkContentColor(.tertiary)
72+
)
73+
74+
private let permissionToggle: UISwitch = {
75+
let toggle = UISwitch()
76+
toggle.onTintColor = .bkBackgroundColor(.primary)
77+
return toggle
78+
}()
79+
80+
private var permissionToggleContainerTopToRequestConstraint: Constraint?
81+
private var permissionToggleContainerTopToSafeAreaConstraint: Constraint?
82+
private var isAnimating = false
83+
84+
override func setupView() {
85+
addSubview(scrollView)
86+
scrollView.addSubview(contentView)
87+
contentView.addSubviews(permissionRequestContainer, permissionToggleContainer)
88+
89+
permissionRequestContainer.addSubview(permissionRequestView)
90+
permissionRequestView.addSubviews(permissionRequestLabelStack, permissionRequestButton)
91+
[permissionRequestTitle, permissionRequestContent]
92+
.forEach(permissionRequestLabelStack.addArrangedSubview(_:))
93+
94+
permissionToggleContainer.addSubviews(permissionToggleLabelStack, permissionToggle)
95+
[permissionToggleTitle, permissionToggleContent]
96+
.forEach(permissionToggleLabelStack.addArrangedSubview(_:))
97+
}
98+
99+
override func setupLayout() {
100+
scrollView.snp.makeConstraints {
101+
$0.edges.equalToSuperview()
102+
}
103+
104+
contentView.snp.makeConstraints {
105+
$0.edges.equalToSuperview()
106+
$0.width.equalToSuperview()
107+
}
108+
109+
permissionRequestContainer.snp.makeConstraints {
110+
$0.top.equalTo(contentView.safeAreaLayoutGuide.snp.top)
111+
$0.leading.trailing.equalToSuperview()
112+
}
113+
114+
permissionRequestView.snp.makeConstraints {
115+
$0.verticalEdges.equalToSuperview()
116+
.inset(LayoutConstants.permissionRequestViewVerticalInset)
117+
$0.horizontalEdges.equalToSuperview()
118+
.inset(LayoutConstants.permissionRequestViewHorizontalInset)
119+
}
120+
121+
permissionRequestLabelStack.snp.makeConstraints {
122+
$0.verticalEdges.equalToSuperview()
123+
.inset(LayoutConstants.permissionRequestLabelPadding)
124+
$0.leading.equalToSuperview()
125+
.inset(LayoutConstants.commonHorizontalInset)
126+
}
127+
128+
permissionRequestButton.snp.makeConstraints {
129+
$0.centerY.equalToSuperview()
130+
$0.trailing.equalToSuperview()
131+
.inset(LayoutConstants.commonHorizontalInset)
132+
}
133+
134+
permissionToggleContainer.snp.makeConstraints {
135+
self.permissionToggleContainerTopToRequestConstraint =
136+
$0.top
137+
.equalTo(permissionRequestContainer.snp.bottom)
138+
.offset(LayoutConstants.toggleSpacingWithRequest)
139+
.constraint
140+
self.permissionToggleContainerTopToSafeAreaConstraint =
141+
$0.top
142+
.equalTo(contentView.safeAreaLayoutGuide.snp.top)
143+
.inset(LayoutConstants.toggleEmptySpacing)
144+
.constraint
145+
$0.leading.trailing.equalToSuperview()
146+
.inset(LayoutConstants.commonHorizontalInset)
147+
$0.bottom.lessThanOrEqualToSuperview()
148+
}
149+
150+
permissionToggleContainerTopToRequestConstraint?.activate()
151+
permissionToggleContainerTopToSafeAreaConstraint?.deactivate()
152+
153+
permissionToggleLabelStack.snp.makeConstraints {
154+
$0.verticalEdges.equalToSuperview()
155+
.inset(LayoutConstants.permissionToggleLabelPadding)
156+
$0.leading.equalToSuperview()
157+
}
158+
159+
permissionToggle.snp.makeConstraints {
160+
$0.centerY.equalToSuperview()
161+
$0.trailing.equalToSuperview()
162+
}
163+
}
164+
165+
override func configure() {
166+
permissionRequestView.backgroundColor = .bkBaseColor(.secondary)
167+
permissionRequestView.layer.cornerRadius = BKRadius.medium
168+
permissionToggle.addTarget(self, action: #selector(didTapPermissionToggle), for: .valueChanged)
169+
170+
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapPermissionRequestView))
171+
permissionRequestView.addGestureRecognizer(tapGesture)
172+
permissionRequestView.isUserInteractionEnabled = true
173+
}
174+
}
175+
176+
// MARK: - Public Methods
177+
extension NotificationSettingsView {
178+
func updateNotificationToggle(isEnabled: Bool, animated: Bool = true) {
179+
guard permissionToggle.isOn != isEnabled else { return }
180+
181+
permissionToggle.setOn(isEnabled, animated: false)
182+
updatePermissionRequestVisibility(isToggleOn: isEnabled, animated: animated)
183+
}
184+
}
185+
186+
private extension NotificationSettingsView {
187+
@objc
188+
func didTapPermissionToggle() {
189+
onNotificationToggleChanged?(permissionToggle.isOn)
190+
updatePermissionRequestVisibility(isToggleOn: permissionToggle.isOn, animated: true)
191+
}
192+
193+
@objc
194+
func didTapPermissionRequestView() {
195+
onPermissionRequestViewTapped?()
196+
}
197+
198+
func updatePermissionRequestVisibility(isToggleOn: Bool, animated: Bool) {
199+
/// 초기엔 애니메이션 없이 즉시 화면 로드
200+
if !animated {
201+
if isToggleOn {
202+
self.permissionRequestContainer.isHidden = true
203+
self.permissionToggleContainerTopToRequestConstraint?.deactivate()
204+
self.permissionToggleContainerTopToSafeAreaConstraint?.activate()
205+
} else {
206+
self.permissionToggleContainerTopToSafeAreaConstraint?.deactivate()
207+
self.permissionToggleContainerTopToRequestConstraint?.activate()
208+
self.permissionRequestContainer.isHidden = false
209+
}
210+
self.layoutIfNeeded()
211+
return
212+
}
213+
214+
guard !isAnimating else { return }
215+
/// 탭 하여 권한 안내 화면이 나오는 경우 애니메이션이 없으면 부자연스러우므로 애니메이션 적용
216+
isAnimating = true
217+
218+
if isToggleOn {
219+
UIView.animate(withDuration: 0.3, animations: {
220+
self.permissionRequestContainer.isHidden = true
221+
self.permissionToggleContainerTopToRequestConstraint?.deactivate()
222+
self.permissionToggleContainerTopToSafeAreaConstraint?.activate()
223+
self.layoutIfNeeded()
224+
}, completion: { _ in
225+
self.isAnimating = false
226+
})
227+
} else {
228+
UIView.animate(withDuration: 0.3, animations: {
229+
self.permissionToggleContainerTopToSafeAreaConstraint?.deactivate()
230+
self.permissionToggleContainerTopToRequestConstraint?.activate()
231+
self.layoutIfNeeded()
232+
}, completion: { _ in
233+
self.permissionRequestContainer.isHidden = false
234+
self.isAnimating = false
235+
})
236+
}
237+
}
238+
}
239+
240+
private extension NotificationSettingsView {
241+
enum LayoutConstants {
242+
static let permissionRequestStackViewSpacing = BKInset.inset05
243+
static let permissionRequestViewVerticalInset = BKInset.inset2
244+
static let permissionRequestViewHorizontalInset = BKInset.inset5
245+
static let commonHorizontalInset = BKInset.inset5
246+
static let toggleEmptySpacing = BKSpacing.spacing4
247+
static let toggleSpacingWithRequest = BKSpacing.spacing2
248+
static let permissionToggleLabelPadding = BKSpacing.spacing4
249+
static let permissionRequestLabelPadding = BKSpacing.spacing6
250+
}
251+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright © 2025 Booket. All rights reserved
2+
3+
import BKCore
4+
import Combine
5+
import UIKit
6+
7+
final class NotificationSettingsViewController: BaseViewController<NotificationSettingsView> {
8+
override var bkNavigationBarStyle: UINavigationController.BKNavigationBarStyle {
9+
return .standard(
10+
viewController: self
11+
)
12+
}
13+
14+
override var bkNavigationTitle: String {
15+
return "알림"
16+
}
17+
18+
weak var coordinator: (NotificationSettingsCoordinator & URLPresenting)?
19+
private var cancellable = Set<AnyCancellable>()
20+
let viewModel: AnyViewBindableViewModel<NotificationSettingsViewModel.State, NotificationSettingsViewModel.Action>
21+
private var isInitialLoad = true
22+
23+
init(viewModel: NotificationSettingsViewModel) {
24+
self.viewModel = AnyViewBindableViewModel(viewModel)
25+
super.init()
26+
}
27+
28+
override func viewDidLoad() {
29+
super.viewDidLoad()
30+
setupViewActions()
31+
}
32+
33+
override func viewWillAppear(_ animated: Bool) {
34+
super.viewWillAppear(animated)
35+
self.tabBarController?.tabBar.isHidden = true
36+
}
37+
38+
override func viewWillDisappear(_ animated: Bool) {
39+
super.viewWillDisappear(animated)
40+
self.tabBarController?.tabBar.isHidden = false
41+
}
42+
43+
private func setupViewActions() {
44+
contentView.onNotificationToggleChanged = { [weak self] isEnabled in
45+
self?.viewModel.send(.notificationToggleTapped(isEnabled))
46+
}
47+
48+
contentView.onPermissionRequestViewTapped = { [weak self] in
49+
self?.openNotificationSettings()
50+
}
51+
}
52+
53+
private func openNotificationSettings() {
54+
coordinator?.presentApp(
55+
url: URL(string: UIApplication.openNotificationSettingsURLString)
56+
)
57+
}
58+
59+
override func bindAction() {
60+
viewModel.send(.onAppear)
61+
}
62+
63+
override func bindState() {
64+
viewModel.statePublisher
65+
.receive(on: DispatchQueue.main)
66+
.map { $0.notificationEnabled }
67+
.sink { [weak self] notificationEnabled in
68+
guard let self = self else { return }
69+
// 초기 로드시에는 애니메이션 없이 즉시 설정
70+
let shouldAnimate = !self.isInitialLoad
71+
self.contentView.updateNotificationToggle(isEnabled: notificationEnabled, animated: shouldAnimate)
72+
self.isInitialLoad = false
73+
}
74+
.store(in: &cancellable)
75+
}
76+
}

0 commit comments

Comments
 (0)