Skip to content

Commit 4e90019

Browse files
authored
Merge pull request #5897 from woocommerce/issue/5842-notice-animated-presentation
2 parents b4f2bf1 + 4e1c563 commit 4e90019

File tree

2 files changed

+248
-0
lines changed

2 files changed

+248
-0
lines changed
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import SwiftUI
2+
import UIKit
3+
4+
/// View Modifier that shows a notice in front of a view.
5+
///
6+
/// NOTE: This currently does not support:
7+
/// - Enqueuing multiple notices like `DefaultNoticePresenter` does.
8+
/// - Presenting foreground system notifications.
9+
///
10+
struct NoticeModifier: ViewModifier {
11+
/// Notice object to render.
12+
///
13+
@Binding var notice: Notice?
14+
15+
/// Cancelable task that clears a notice.
16+
///
17+
@State private var clearNoticeTask = DispatchWorkItem(block: {})
18+
19+
/// Time the notice will remain on screen.
20+
///
21+
private let onScreenNoticeTime = 5.0
22+
23+
/// Feedback generator.
24+
///
25+
private let feedbackGenerator = UINotificationFeedbackGenerator()
26+
27+
/// Current horizontal size class.
28+
///
29+
@Environment(\.horizontalSizeClass) var horizontalSizeClass
30+
31+
func body(content: Content) -> some View {
32+
content
33+
.overlay(buildNoticeStack())
34+
.animation(.easeInOut, value: notice)
35+
}
36+
37+
/// Builds a notice view at the bottom of the screen.
38+
///
39+
@ViewBuilder private func buildNoticeStack() -> some View {
40+
if let notice = notice {
41+
// Geometry reader to provide the correct view width.
42+
GeometryReader { geometry in
43+
44+
// VStack with spacer to push content to the bottom
45+
VStack {
46+
Spacer()
47+
48+
// NoticeView wrapper
49+
NoticeAlert(notice: notice, width: preferredSizeClassWidth(geometry))
50+
.onDismiss {
51+
performClearNoticeTask()
52+
}
53+
.onChange(of: notice) { _ in
54+
provideHapticFeedbackIfNecessary(notice.feedbackType)
55+
dispatchClearNoticeTask()
56+
}
57+
.onAppear {
58+
provideHapticFeedbackIfNecessary(notice.feedbackType)
59+
dispatchClearNoticeTask()
60+
}
61+
.fixedSize()
62+
}
63+
.frame(width: geometry.size.width) // Force a full container width so the notice is always centered.
64+
}
65+
}
66+
}
67+
68+
/// Cancels any ongoing clear notice task and dispatches it again.
69+
///
70+
private func dispatchClearNoticeTask() {
71+
clearNoticeTask.cancel()
72+
clearNoticeTask = .init {
73+
$notice.wrappedValue = nil
74+
}
75+
DispatchQueue.main.asyncAfter(deadline: .now() + onScreenNoticeTime, execute: clearNoticeTask)
76+
}
77+
78+
/// Synchronously performs the clear notice task and cancels it to prevent any future execution.
79+
///
80+
private func performClearNoticeTask() {
81+
clearNoticeTask.perform()
82+
clearNoticeTask.cancel()
83+
}
84+
85+
/// Sends haptic feedback if required.
86+
///
87+
private func provideHapticFeedbackIfNecessary(_ feedbackType: UINotificationFeedbackGenerator.FeedbackType?) {
88+
if let feedbackType = feedbackType {
89+
feedbackGenerator.notificationOccurred(feedbackType)
90+
}
91+
}
92+
93+
/// Returns a scaled width for a regular horizontal size class.
94+
///
95+
private func preferredSizeClassWidth(_ geometry: GeometryProxy) -> CGFloat {
96+
let multiplier = horizontalSizeClass == .regular ? 0.5 : 1.0
97+
return geometry.size.width * multiplier
98+
}
99+
}
100+
101+
// MARK: Custom Views
102+
103+
/// `SwiftUI` representable type for `NoticeView`.
104+
///
105+
private struct NoticeAlert: UIViewRepresentable {
106+
107+
/// Notice object to render.
108+
///
109+
let notice: Notice
110+
111+
/// Desired width of the view.
112+
///
113+
let width: CGFloat
114+
115+
/// Action to be invoked when the view is tapped.
116+
///
117+
var onDismiss: (() -> Void)?
118+
119+
func makeUIView(context: Context) -> NoticeWrapper {
120+
let noticeView = NoticeView(notice: notice)
121+
let wrapperView = NoticeWrapper(noticeView: noticeView)
122+
wrapperView.translatesAutoresizingMaskIntoConstraints = false
123+
return wrapperView
124+
}
125+
126+
func updateUIView(_ uiView: NoticeWrapper, context: Context) {
127+
uiView.noticeView = NoticeView(notice: notice)
128+
uiView.noticeView.dismissHandler = onDismiss
129+
uiView.width = width
130+
}
131+
132+
/// Updates the notice dismiss closure.
133+
///
134+
func onDismiss(_ onDismiss: @escaping (() -> Void)) -> Self {
135+
var copy = self
136+
copy.onDismiss = onDismiss
137+
return copy
138+
}
139+
}
140+
141+
142+
private extension NoticeAlert {
143+
/// Wrapper type to force the underlying `NoticeView` to have a fixed width.
144+
///
145+
class NoticeWrapper: UIView {
146+
/// Underlying notice view
147+
///
148+
var noticeView: NoticeView {
149+
didSet {
150+
oldValue.removeFromSuperview()
151+
setUpNoticeLayout()
152+
}
153+
}
154+
155+
/// Fixed width constraint.
156+
///
157+
var width: CGFloat = 0 {
158+
didSet {
159+
noticeViewWidthConstraint.constant = width
160+
}
161+
}
162+
163+
/// Width constraint for the notice view.
164+
///
165+
private var noticeViewWidthConstraint = NSLayoutConstraint()
166+
167+
/// Notice view padding.
168+
///
169+
let defaultInsets = UIEdgeInsets(top: 16, left: 16, bottom: 32, right: 16)
170+
171+
init(noticeView: NoticeView) {
172+
self.noticeView = noticeView
173+
super.init(frame: .zero)
174+
175+
setUpNoticeLayout()
176+
createWidthConstraint()
177+
}
178+
179+
/// Set ups the notice layout.
180+
///
181+
private func setUpNoticeLayout() {
182+
// Add notice view to edges
183+
noticeView.translatesAutoresizingMaskIntoConstraints = false
184+
addSubview(noticeView)
185+
186+
layoutMargins = defaultInsets
187+
pinSubviewToAllEdgeMargins(noticeView)
188+
}
189+
190+
/// Forces the wrapper view to have a fixed width.
191+
///
192+
private func createWidthConstraint() {
193+
noticeViewWidthConstraint = widthAnchor.constraint(equalToConstant: width)
194+
noticeViewWidthConstraint.isActive = true
195+
}
196+
197+
/// Returns the preferred size of the view using the fixed width.
198+
///
199+
override var intrinsicContentSize: CGSize {
200+
let targetSize = CGSize(width: width - defaultInsets.left - defaultInsets.right, height: 0)
201+
let noticeHeight = noticeView.systemLayoutSizeFitting(
202+
targetSize,
203+
withHorizontalFittingPriority: .required,
204+
verticalFittingPriority: .defaultLow
205+
).height
206+
return CGSize(width: width, height: noticeHeight + defaultInsets.top + defaultInsets.bottom)
207+
}
208+
209+
required init?(coder: NSCoder) {
210+
fatalError("init(coder:) has not been implemented")
211+
}
212+
}
213+
}
214+
215+
// MARK: View Extension
216+
217+
extension View {
218+
/// Shows the provided notice in front of the view.
219+
///
220+
func notice(_ notice: Binding<Notice?>) -> some View {
221+
modifier(NoticeModifier(notice: notice))
222+
}
223+
}
224+
225+
// MARK: Preview
226+
227+
struct NoticeModifier_Previews: PreviewProvider {
228+
static var previews: some View {
229+
Rectangle().foregroundColor(.white)
230+
.notice(.constant(
231+
.init(title: "API Error",
232+
subtitle: "Restricted Access",
233+
message: "Your photos could not be downloaded, please ask for the correct permissions!",
234+
feedbackType: .error,
235+
notificationInfo: nil,
236+
actionTitle: "Retry",
237+
actionHandler: {
238+
print("Retry")
239+
})
240+
))
241+
.environment(\.colorScheme, .light)
242+
.previewDisplayName("Light Content")
243+
}
244+
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@
417417
261AA309275178FA009530FE /* SimplePaymentsMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 261AA308275178FA009530FE /* SimplePaymentsMethod.swift */; };
418418
261AA30C2753119E009530FE /* SimplePaymentsMethodsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 261AA30B2753119E009530FE /* SimplePaymentsMethodsViewModel.swift */; };
419419
261AA30E275506DE009530FE /* SimplePaymentsMethodsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 261AA30D275506DE009530FE /* SimplePaymentsMethodsViewModelTests.swift */; };
420+
26281776278F0B0100C836D3 /* View+NoticesModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26281775278F0B0100C836D3 /* View+NoticesModifier.swift */; };
420421
262A09812628A8F40033AD20 /* WooStyleModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262A09802628A8F40033AD20 /* WooStyleModifiers.swift */; };
421422
262A098B2628C51D0033AD20 /* OrderAddOnListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262A098A2628C51D0033AD20 /* OrderAddOnListViewModel.swift */; };
422423
262A0999262908A60033AD20 /* OrderAddOnListI1Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262A0998262908A60033AD20 /* OrderAddOnListI1Tests.swift */; };
@@ -2015,6 +2016,7 @@
20152016
261AA308275178FA009530FE /* SimplePaymentsMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimplePaymentsMethod.swift; sourceTree = "<group>"; };
20162017
261AA30B2753119E009530FE /* SimplePaymentsMethodsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimplePaymentsMethodsViewModel.swift; sourceTree = "<group>"; };
20172018
261AA30D275506DE009530FE /* SimplePaymentsMethodsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimplePaymentsMethodsViewModelTests.swift; sourceTree = "<group>"; };
2019+
26281775278F0B0100C836D3 /* View+NoticesModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+NoticesModifier.swift"; sourceTree = "<group>"; };
20182020
262A09802628A8F40033AD20 /* WooStyleModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooStyleModifiers.swift; sourceTree = "<group>"; };
20192021
262A098A2628C51D0033AD20 /* OrderAddOnListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderAddOnListViewModel.swift; sourceTree = "<group>"; };
20202022
262A0998262908A60033AD20 /* OrderAddOnListI1Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderAddOnListI1Tests.swift; sourceTree = "<group>"; };
@@ -4225,6 +4227,7 @@
42254227
26E0AE12263359F900A5EB3B /* View+Conditionals.swift */,
42264228
DE4B3B5526A68DD000EEF2D8 /* View+InsetPaddings.swift */,
42274229
581D5051274AA2480089B6AD /* View+AutofocusTextModifier.swift */,
4230+
26281775278F0B0100C836D3 /* View+NoticesModifier.swift */,
42284231
);
42294232
path = "View Modifiers";
42304233
sourceTree = "<group>";
@@ -8198,6 +8201,7 @@
81988201
7435E58E21C0151B00216F0F /* OrderNote+Woo.swift in Sources */,
81998202
451A04F42386F7C900E368C9 /* AddProductImageCollectionViewCell.swift in Sources */,
82008203
D843D5CB22437E59001BFA55 /* TitleAndEditableValueTableViewCell.swift in Sources */,
8204+
26281776278F0B0100C836D3 /* View+NoticesModifier.swift in Sources */,
82018205
025B1748237A92D800C780B4 /* ProductFormSection+ReusableTableRow.swift in Sources */,
82028206
CC4A4ED82655478D00B75DCD /* ShippingLabelPaymentMethodsViewModel.swift in Sources */,
82038207
B5A8F8A920B84D3F00D211DE /* ApiCredentials.swift in Sources */,

0 commit comments

Comments
 (0)