import Combine
import SwiftUI
import ThreemaFramework
import ThreemaMacros
struct EmojiReactionModal: View {
@Environment(\.presentationMode) private var presentationMode
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
@Namespace private var menuItemTransition
@State private var selectedIndex = 0
@StateObject var model: ReactionModel
var body: some View {
if selectedIndex <= model.reactionEntries.count - 1 {
let reaction = model.reactionEntries[selectedIndex]
VStack(spacing: 0) {
Spacer(minLength: 8)
reactionsRow
userList(
reaction.userReactionEntries,
for: reaction,
canBeRemoved: reaction.canBeRemoved
)
}
.apply { view in
if #available(iOS 26.0, *) {
view
.cornerRadius(30)
.padding(.top, 5)
}
else {
view
.cornerRadius(20)
.padding(.top, 10)
}
}
.background(Color(UIColor.systemBackground))
}
else {
VStack(spacing: 0) {
Text(verbatim: "Error loading reactions. Try again later.")
}
.onAppear {
dismiss()
}
}
}
private var menuItems: some View {
ForEach(model.reactionEntries) { reactionEntry in
let index = model.reactionEntries.firstIndex(of: reactionEntry) ?? 0
VStack {
MenuBarItem(
namespace: menuItemTransition,
isSelected: reactionEntry.reactedByMe,
value: Double(model.prevCountPerReaction[reactionEntry.reaction] ?? 0), content: .init(
emoji: reactionEntry.displayValue,
count: model.countPerReaction[reactionEntry.reaction] ?? 0
)
)
.padding(.top, 6)
.padding(.bottom, 4)
ZStack {
if selectedIndex == index {
Rectangle()
.frame(height: 2)
.foregroundColor(Colors.chatReactionBubbleTextColor.color)
.matchedGeometryEffect(id: "menuItemTransition", in: menuItemTransition)
}
Rectangle()
.frame(height: 2)
.foregroundColor(.clear)
}
}
.id(reactionEntry)
.frame(idealWidth: UIScreen.main.bounds.width / 5.5)
.onTapGesture {
withAnimation {
selectedIndex = index
}
}
}
}
private var reactionsRow: some View {
ScrollViewReader { scrollView in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
menuItems
}
}
.onChange(of: selectedIndex) {
Task { @MainActor in
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
withAnimation {
scrollView.scrollTo(selectedIndex, anchor: .center)
}
}
.background {
VStack {
Spacer()
Rectangle()
.frame(height: 2)
.foregroundColor(.gray.opacity(0.5))
}
}
}
}
private func userList(
_ userReactions: [UserReactionEntry],
for reaction: ReactionEntry,
canBeRemoved: Bool
) -> some View {
let sorted = userReactions.sorted {
$0.isMe && !$1.isMe
}
return List(sorted) { user in
HStack {
user.profileImage
.resizable()
.scaledToFill()
.frame(width: 40, height: 40)
.clipShape(Circle())
.accessibilityHidden(true)
Text(user.name)
.font(.subheadline)
Spacer()
}
.accessibilityElement(children: .combine)
.overlay(alignment: .trailing) {
if canBeRemoved,
user.isMe,
let emoji = EmojiVariant(rawValue: reaction.reaction) {
HStack {
Spacer()
Button(role: .destructive) {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
model.removeOwnReaction(emoji)
}
label: {
Image(systemName: "trash")
.foregroundColor(.red)
}
.accessibilityLabel(#localize("delete"))
}
}
}
}
.listStyle(.plain)
}
}
// MARK: - EmojiReactionModal.MenuBarItem
extension EmojiReactionModal {
struct MenuBarItem: View {
struct Content {
let emoji: String
let count: Int
}
let namespace: Namespace.ID
var isSelected: Bool
@State var value: Double = 0
@State var content: Content
var body: some View {
VStack {
HStack(spacing: 2) {
Text(content.emoji)
.font(.title3)
Text(String(format: "%.0f", value))
.contentTransition(.numericText(value: value))
.font(.subheadline)
.fontWeight(isSelected ? .bold : .regular)
.foregroundColor(Colors.chatReactionBubbleTextColor.color)
}
}
.dynamicTypeSize(...DynamicTypeSize.accessibility3)
.accessibilityElement(children: .combine)
.onAppear {
updateValue(
oldCount: Int(value),
newCount: content.count
)
}
}
private func updateValue(oldCount: Int, newCount: Int) {
if newCount > oldCount {
let delta = newCount - oldCount
withAnimation {
value += Double(delta)
}
}
else if newCount < oldCount {
let delta = oldCount - newCount
withAnimation {
value -= Double(delta)
}
}
}
}
}
typealias EmojiReactionModalViewController = UIHostingController<EmojiReactionModal>
extension EmojiReactionModalViewController {
static func sheet(_ reactionsManager: any ReactionsModalDelegate) -> EmojiReactionModalViewController {
UIHostingController(
rootView: EmojiReactionModal(model: .init(reactionsManager))
).then { vc in
if let sheetController = vc.presentationController as? UISheetPresentationController {
sheetController.detents = [.medium(), .large()]
sheetController.prefersGrabberVisible = true
sheetController.prefersScrollingExpandsWhenScrolledToEdge = true
}
let window = UIApplication.shared.windows.first(where: \.isKeyWindow)
let inverseSafeAreaInset = window?.safeAreaInsets.bottom ?? 0
vc.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: -inverseSafeAreaInset, right: 0)
}
}
}
¤ Dauer der Verarbeitung: 0.3 Sekunden
¤
*© Formatika GbR, Deutschland