From ff8802c8ddd3cea718c935488662a2fe98cc0727 Mon Sep 17 00:00:00 2001 From: Salim Braksa Date: Sun, 5 May 2024 19:17:31 +0100 Subject: [PATCH 1/5] Improve comment moderation sheet transition smoothness --- .../CommentModerationState.swift | 6 +- .../CommentModerationView.swift | 391 ++++++++++-------- .../CommentModerationViewModel.swift | 14 +- 3 files changed, 229 insertions(+), 182 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationState.swift b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationState.swift index 94322a80e308..a462e4970a72 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationState.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationState.swift @@ -1,6 +1,6 @@ -enum CommentModerationState: CaseIterable { +enum CommentModerationState: Equatable { case pending - case approved - case liked + case approved(liked: Bool) + case spam case trash } diff --git a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift index efda84b08ed5..6c9196533ce3 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift @@ -13,106 +13,182 @@ struct CommentModerationView: View { Divider() .foregroundStyle(Color.DS.Background.secondary) VStack(spacing: .DS.Padding.double) { - titleHStack - mainActionView - secondaryActionView + switch viewModel.state { + case .pending: + Pending(viewModel: viewModel) + case .approved(let liked): + Approved(viewModel: viewModel, liked: liked) + case .trash, .spam: + TrashSpam(viewModel: viewModel) + } } .padding(.horizontal, .DS.Padding.double) } + .animation(.smooth, value: viewModel.state) } +} + +// MARK: - Subviews + +private struct Container: View { - private var titleHStack: some View { + let title: String + let icon: V + let content: T + + private let animationDuration = 0.25 + + private var animation: Animation { + return .smooth(duration: animationDuration) + } + + init(title: String, @ViewBuilder icon: () -> V = { EmptyView() }, @ViewBuilder content: () -> T) { + self.content = content() + self.icon = icon() + self.title = title + } + + var body: some View { + VStack(spacing: .DS.Padding.double) { + titleHStack + content + } + .padding(.horizontal, .DS.Padding.double) + .transition( + .asymmetric( + insertion: .opacity.animation(animation.delay(animationDuration * 1)), + removal: .opacity.animation(animation) + ) + ) + } + + var titleHStack: some View { HStack(spacing: 0) { - switch viewModel.state { - case .pending: - Image.DS.icon(named: .clock) - .resizable() - .renderingMode(.template) - .frame(width: .DS.Padding.double, height: .DS.Padding.double) - .foregroundStyle(Color.DS.Foreground.secondary) - .padding(.trailing, .DS.Padding.half) - case .approved, .liked: - Image.DS.icon(named: .checkmark) - .resizable() - .frame(width: 20, height: 20) - .foregroundStyle(Color.DS.Foreground.secondary) - .padding(.trailing, 2) - case .trash: - EmptyView() - } - Text(viewModel.state.title) + icon + Text(title) .style(.caption) .foregroundStyle(Color.DS.Foreground.secondary) } } +} - @ViewBuilder - private var mainActionView: some View { - switch viewModel.state.mainAction { - case let .cta(title, iconName): - DSButton( - title: title, - iconName: iconName, - style: DSButtonStyle( - emphasis: .primary, - size: .large - )) { - withAnimation(.smooth) { +private struct Pending: View { + + let viewModel: CommentModerationViewModel + + var body: some View { + Container(title: Strings.title, icon: { icon }) { + VStack { + DSButton( + title: Strings.approveComment, + iconName: .checkmark, + style: DSButtonStyle( + emphasis: .primary, + size: .large + )) { viewModel.didTapPrimaryCTA() } - } - case .reply: - ContentPreview( - image: .init(url: viewModel.imageURL), - text: viewModel.userName - ) { - viewModel.didTapReply() + DSButton( + title: Strings.moreOptions, + style: .init(emphasis: .tertiary, size: .large)) { + viewModel.didTapMore() + } } } } @ViewBuilder - private var secondaryActionView: some View { - if case .more = viewModel.state.secondaryAction { - DSButton( - title: Strings.moreOptionsButtonTitle, - style: .init(emphasis: .tertiary, size: .large)) { - viewModel.didTapMore() + var icon: some View { + Image.DS.icon(named: .clock) + .resizable() + .renderingMode(.template) + .frame(width: .DS.Padding.double, height: .DS.Padding.double) + .foregroundStyle(Color.DS.Foreground.secondary) + .padding(.trailing, .DS.Padding.half) + } + + enum Strings { + static let title = NSLocalizedString( + "notifications.comment.moderation.pending.title", + value: "Comment pending moderation", + comment: "Title for Comment Moderation Pending State" + ) + static let approveComment = NSLocalizedString( + "notifications.comment.approval.cta.title", + value: "Approve Comment", + comment: "Title for Comment Approval CTA" + ) + static let moreOptions = NSLocalizedString( + "notifications.comment.moderation.more.cta.title", + value: "More options", + comment: "More button title for comment moderation options sheet." + ) + } +} + +private struct Approved: View { + + let viewModel: CommentModerationViewModel + let liked: Bool + + private var likeButtonTitle: String { + liked ? Strings.commentLikedTitle : String(format: Strings.commentLikeTitle, viewModel.userName) + } + + var body: some View { + Container(title: Strings.title, icon: { icon }) { + VStack { + ContentPreview( + image: .init(url: viewModel.imageURL, placeholder: Image("gravatar")), + text: viewModel.userName + ) { } - } else if case .like = viewModel.state.secondaryAction { - DSButton( - title: viewModel.state == .approved ? String( - format: Strings.commentLikeTitle, - viewModel.userName - ) : Strings.commentLikedTitle, - iconName: viewModel.state == .approved ? .starOutline : .starFill, - style: .init( - emphasis: .tertiary, - size: .large - ) - ) { - withAnimation(.interactiveSpring) { - viewModel.didTapLike() + DSButton( + title: likeButtonTitle, + iconName: liked ? .starFill : .starOutline, + style: .init( + emphasis: .tertiary, + size: .large + ) + ) { + withAnimation(.interactiveSpring) { + viewModel.didTapLike() + } } } } } -} -private extension CommentModerationView { + @ViewBuilder + var icon: some View { + Image.DS.icon(named: .checkmark) + .resizable() + .frame(width: 20, height: 20) + .foregroundStyle(Color.DS.Foreground.secondary) + .padding(.trailing, 2) + } + enum Strings { - static let moreOptionsButtonTitle = NSLocalizedString( + static let title = NSLocalizedString( + "notifications.comment.moderation.approved.title", + value: "Comment approved", + comment: "Title for Comment Moderation Approved State" + ) + static let approveComment = NSLocalizedString( + "notifications.comment.approval.cta.title", + value: "Approve Comment", + comment: "Title for Comment Approval CTA" + ) + static let moreOptions = NSLocalizedString( "notifications.comment.moderation.more.cta.title", value: "More options", comment: "More button title for comment moderation options sheet." ) - static let commentLikedTitle = NSLocalizedString( "notifications.comment.liked.title", value: "Comment liked", comment: "Liked state title for comment like button." ) - static let commentLikeTitle = NSLocalizedString( "notifications.comment.like.title", value: "Like %@'s comment", @@ -121,126 +197,91 @@ private extension CommentModerationView { } } -private extension CommentModerationState { - enum MainAction { - case cta(title: String, iconName: IconName) - case reply - } - - enum SecondaryAction { - case more - case like - case none - } - - var title: String { - switch self { - case .pending: - return NSLocalizedString( - "notifications.comment.moderation.pending.title", - value: "Comment pending moderation", - comment: "Title for Comment Moderation Pending State") - case .approved, .liked: - return NSLocalizedString( - "notifications.comment.moderation.approved.title", - value: "Comment approved", - comment: "Title for Comment Moderation Approved State") - case .trash: - return NSLocalizedString( - "notifications.comment.moderation.trash.title", - value: "Comment in trash", - comment: "Title for Comment Moderation Trash State") - } +private struct TrashSpam: View { + + @ObservedObject var viewModel: CommentModerationViewModel + + @State var title: String + + init(viewModel: CommentModerationViewModel) { + self.viewModel = viewModel + self.title = Self.title(for: viewModel.state) ?? "" } - var mainAction: MainAction { - switch self { - case .pending: - return .cta( - title: NSLocalizedString( - "notifications.comment.approval.cta.title", - value: "Approve Comment", - comment: "Title for Comment Approval CTA" - ), - iconName: .checkmark - ) - case .approved: - return .reply - case .liked: - return .reply - case .trash: - return .cta( - title: NSLocalizedString( - "notifications.comment.delete.cta.title", - value: "Delete Permanently", - comment: "Title for Comment Deletion CTA" - ), - iconName: .trash - ) + var body: some View { + Container(title: title) { + DSButton( + title: Strings.delete, + iconName: .trash, + style: .init( + emphasis: .primary, + size: .large + ) + ) { + } + }.onChange(of: viewModel.state) { state in + if let title = Self.title(for: state) { + self.title = title + } } } - var secondaryAction: SecondaryAction { - switch self { - case .pending: - return .more - case .approved, .liked: - return .like - case .trash: - return .none + static func title(for state: CommentModerationState) -> String? { + switch state { + case .spam: return Strings.spamTitle + case .trash: return Strings.trashTitle + default: return nil } } + + enum Strings { + static let trashTitle = NSLocalizedString( + "notifications.comment.moderation.trash.title", + value: "Comment in trash", + comment: "Title for Comment Moderation Trash State" + ) + static let spamTitle = NSLocalizedString( + "notifications.comment.moderation.spam.title", + value: "Comment in spam", + comment: "Title for Comment Moderation Spam State" + ) + static let delete = NSLocalizedString( + "notifications.comment.delete.cta.title", + value: "Delete Permanently", + comment: "Title for Comment Deletion CTA" + ) + } } -#Preview { - GeometryReader { proxy in - if #available(iOS 17.0, *) { - ScrollView(.horizontal) { - LazyHStack(spacing: 0) { - CommentModerationView( - viewModel: CommentModerationViewModel( - state: .pending, - imageURL: URL(string: "https://i.pravatar.cc/300"), - userName: "John Smith" - ) - ) - .frame( - width: proxy.size.width - ) - CommentModerationView( - viewModel: CommentModerationViewModel( - state: .approved, - imageURL: URL(string: "https://i.pravatar.cc/300"), - userName: "Jane Smith" - ) - ) - .frame( - width: proxy.size.width - ) - CommentModerationView( - viewModel: CommentModerationViewModel( - state: .liked, - imageURL: URL(string: "https://i.pravatar.cc/300"), - userName: "John Smith" - ) - ) - .frame( - width: proxy.size.width - ) - CommentModerationView( - viewModel: CommentModerationViewModel( - state: .trash, - imageURL: URL(string: "https://i.pravatar.cc/300"), - userName: "Jane Smith" - ) - ) - .frame( - width: proxy.size.width - ) +// MARK: - Preview + +struct CommentModerationView_Previews: PreviewProvider { + static let viewModel = CommentModerationViewModel( + state: .pending, + imageURL: URL(string: "https://i.pravatar.cc/300"), + userName: "John Smith" + ) + + static var previews: some View { + ZStack { + VStack(spacing: .DS.Padding.double) { + Button("Pending") { + viewModel.state = .pending + } + Button("Approve") { + viewModel.state = .approved(liked: false) } + Button("Spam") { + viewModel.state = .spam + } + Button("Trash") { + viewModel.state = .trash + } + } + VStack { + Spacer() + CommentModerationView(viewModel: viewModel) } - .scrollTargetBehavior(.paging) - .scrollIndicators(.hidden) } } } diff --git a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationViewModel.swift b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationViewModel.swift index fa29e45d19ac..c62cf9a81cf1 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationViewModel.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationViewModel.swift @@ -1,5 +1,6 @@ final class CommentModerationViewModel: ObservableObject { @Published var state: CommentModerationState + let imageURL: URL? let userName: String @@ -21,10 +22,10 @@ final class CommentModerationViewModel: ObservableObject { func didTapPrimaryCTA() { switch state { case .pending: - state = .approved - case .trash: + state = .approved(liked: false) + case .trash, .spam: () // Delete comment - case .approved, .liked: + case .approved: break } } @@ -34,6 +35,11 @@ final class CommentModerationViewModel: ObservableObject { } func didTapLike() { - state = state == .approved ? .liked : .approved + switch state { + case .approved(let liked): + state = .approved(liked: !liked) + default: + break + } } } From d1ed21d2f44145614632154f1148aea502006c08 Mon Sep 17 00:00:00 2001 From: Salim Braksa Date: Sun, 5 May 2024 20:20:42 +0100 Subject: [PATCH 2/5] Decrease insertion animation delay --- .../Comment Moderation/CommentModerationView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift index 6c9196533ce3..98e147d58e6d 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift @@ -56,7 +56,7 @@ private struct Container: View { .padding(.horizontal, .DS.Padding.double) .transition( .asymmetric( - insertion: .opacity.animation(animation.delay(animationDuration * 1)), + insertion: .opacity.animation(animation.delay(animationDuration * 0.8)), removal: .opacity.animation(animation) ) ) From eccb0167a4dc14a4fc297917775088bc75caf427 Mon Sep 17 00:00:00 2001 From: Salim Braksa Date: Sun, 5 May 2024 20:22:36 +0100 Subject: [PATCH 3/5] Fix a minor typo --- .../Comment Moderation/CommentModerationView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift index 98e147d58e6d..5d0b82f95ab6 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift @@ -268,7 +268,7 @@ struct CommentModerationView_Previews: PreviewProvider { Button("Pending") { viewModel.state = .pending } - Button("Approve") { + Button("Approved") { viewModel.state = .approved(liked: false) } Button("Spam") { From 8213bfdef38571310589634c238ae7fd91ea9d59 Mon Sep 17 00:00:00 2001 From: Salim Braksa Date: Sun, 5 May 2024 20:31:56 +0100 Subject: [PATCH 4/5] Fix lint issue --- .../Comment Moderation/CommentModerationViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationViewModel.swift b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationViewModel.swift index c62cf9a81cf1..326ef09876cd 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationViewModel.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationViewModel.swift @@ -1,6 +1,6 @@ final class CommentModerationViewModel: ObservableObject { @Published var state: CommentModerationState - + let imageURL: URL? let userName: String From fc2c17644f4e5ac1406b0ccb6204b1f5f1a92aa4 Mon Sep 17 00:00:00 2001 From: Salim Braksa Date: Thu, 9 May 2024 01:12:24 +0100 Subject: [PATCH 5/5] Define transition and animation delay as constants --- .../Comment Moderation/CommentModerationView.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift index 5d0b82f95ab6..2742dafda005 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Comment Moderation/CommentModerationView.swift @@ -36,9 +36,17 @@ private struct Container: View { let icon: V let content: T + private var transition: AnyTransition = .opacity + private let animationDuration = 0.25 - private var animation: Animation { + private let insertionAnimationDelay = 0.8 + + private var insertionAnimation: Animation { + return removalAnimation.delay(insertionAnimationDelay) + } + + private var removalAnimation: Animation { return .smooth(duration: animationDuration) } @@ -56,8 +64,8 @@ private struct Container: View { .padding(.horizontal, .DS.Padding.double) .transition( .asymmetric( - insertion: .opacity.animation(animation.delay(animationDuration * 0.8)), - removal: .opacity.animation(animation) + insertion: transition.animation(insertionAnimation), + removal: transition.animation(removalAnimation) ) ) }