diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 2b5eaaa75..19cfcd91d 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -177,7 +177,6 @@ 41A1994229D2D3920031AD75 /* SwipePanGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A1994129D2D3920031AD75 /* SwipePanGestureRecognizer.swift */; }; 41A1994429D2D3CF0031AD75 /* MessageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A1994329D2D3CF0031AD75 /* MessageModel.swift */; }; 41A1994629D2FCF80031AD75 /* ReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A1994529D2FCF80031AD75 /* ReplyView.swift */; }; - 41A1994829D325800031AD75 /* SwipeableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A1994729D325800031AD75 /* SwipeableView.swift */; }; 41A1995229D42C460031AD75 /* ChatMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A1995129D42C460031AD75 /* ChatMessageCell.swift */; }; 41A1995429D56E340031AD75 /* ChatMessageReplyCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A1995329D56E340031AD75 /* ChatMessageReplyCell.swift */; }; 41A1995629D56EAA0031AD75 /* ChatMessageReplyCell+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A1995529D56EAA0031AD75 /* ChatMessageReplyCell+Model.swift */; }; @@ -356,6 +355,9 @@ 9377FBDF296C2A2F00C9211B /* ChatTransactionContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9377FBDE296C2A2F00C9211B /* ChatTransactionContentView.swift */; }; 9377FBE2296C2ACA00C9211B /* ChatTransactionContentView+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9377FBE1296C2ACA00C9211B /* ChatTransactionContentView+Model.swift */; }; 937EDFC02C9CF6B300F219BB /* VersionFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937EDFBF2C9CF6B300F219BB /* VersionFooterView.swift */; }; + 9380EF632D1119DD006939E1 /* ChatSwipeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9380EF622D1119DD006939E1 /* ChatSwipeWrapper.swift */; }; + 9380EF662D111BD1006939E1 /* ChatSwipeWrapperModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9380EF652D111BD1006939E1 /* ChatSwipeWrapperModel.swift */; }; + 9380EF682D112BB9006939E1 /* ChatSwipeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9380EF672D112BB9006939E1 /* ChatSwipeManager.swift */; }; 9382F61329DEC0A3005E6216 /* ChatModelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9382F61229DEC0A3005E6216 /* ChatModelView.swift */; }; 938F7D582955C1DA001915CA /* MessageKit in Frameworks */ = {isa = PBXBuildFile; productRef = 938F7D572955C1DA001915CA /* MessageKit */; }; 938F7D5B2955C8DA001915CA /* ChatDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938F7D5A2955C8DA001915CA /* ChatDisplayManager.swift */; }; @@ -854,7 +856,6 @@ 41A1994129D2D3920031AD75 /* SwipePanGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipePanGestureRecognizer.swift; sourceTree = ""; }; 41A1994329D2D3CF0031AD75 /* MessageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageModel.swift; sourceTree = ""; }; 41A1994529D2FCF80031AD75 /* ReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyView.swift; sourceTree = ""; }; - 41A1994729D325800031AD75 /* SwipeableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeableView.swift; sourceTree = ""; }; 41A1995129D42C460031AD75 /* ChatMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageCell.swift; sourceTree = ""; }; 41A1995329D56E340031AD75 /* ChatMessageReplyCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageReplyCell.swift; sourceTree = ""; }; 41A1995529D56EAA0031AD75 /* ChatMessageReplyCell+Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatMessageReplyCell+Model.swift"; sourceTree = ""; }; @@ -1028,6 +1029,9 @@ 9377FBDE296C2A2F00C9211B /* ChatTransactionContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTransactionContentView.swift; sourceTree = ""; }; 9377FBE1296C2ACA00C9211B /* ChatTransactionContentView+Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatTransactionContentView+Model.swift"; sourceTree = ""; }; 937EDFBF2C9CF6B300F219BB /* VersionFooterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VersionFooterView.swift; sourceTree = ""; }; + 9380EF622D1119DD006939E1 /* ChatSwipeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSwipeWrapper.swift; sourceTree = ""; }; + 9380EF652D111BD1006939E1 /* ChatSwipeWrapperModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSwipeWrapperModel.swift; sourceTree = ""; }; + 9380EF672D112BB9006939E1 /* ChatSwipeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSwipeManager.swift; sourceTree = ""; }; 9382F61229DEC0A3005E6216 /* ChatModelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModelView.swift; sourceTree = ""; }; 938F7D5A2955C8DA001915CA /* ChatDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDisplayManager.swift; sourceTree = ""; }; 938F7D5C2955C8F9001915CA /* ChatLayoutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLayoutManager.swift; sourceTree = ""; }; @@ -1974,6 +1978,15 @@ path = ChatTransaction; sourceTree = ""; }; + 9380EF642D111BBF006939E1 /* ChatSwipeWrapper */ = { + isa = PBXGroup; + children = ( + 9380EF622D1119DD006939E1 /* ChatSwipeWrapper.swift */, + 9380EF652D111BD1006939E1 /* ChatSwipeWrapperModel.swift */, + ); + path = ChatSwipeWrapper; + sourceTree = ""; + }; 938F7D552955C05D001915CA /* Chat */ = { isa = PBXGroup; children = ( @@ -1994,6 +2007,7 @@ 938F7D602955C92B001915CA /* ChatDataSourceManager.swift */, 9390C5022976B42800270CDF /* ChatDialogManager.swift */, 932F77582989F999006D8801 /* ChatCellManager.swift */, + 9380EF672D112BB9006939E1 /* ChatSwipeManager.swift */, 411DB8322A14D01F006AB158 /* ChatKeyboardManager.swift */, 418FDE4F2A25CA340055E3CD /* ChatMenuManager.swift */, 9340077F29AC341000A20622 /* ChatAction.swift */, @@ -2033,6 +2047,7 @@ 93996A9829682690008D080B /* Subviews */ = { isa = PBXGroup; children = ( + 9380EF642D111BBF006939E1 /* ChatSwipeWrapper */, 3A299C742B84CE1400B54C61 /* FilesToolBarView */, 3A299C672B838A7800B54C61 /* ChatMedia */, 41A1995029D42C160031AD75 /* ChatBaseMessage */, @@ -2790,7 +2805,6 @@ 93F3914F2962F5D400BFD6AE /* SpinnerView.swift */, 4133AED329769EEC00F3D017 /* UpdatingIndicatorView.swift */, 41A1994529D2FCF80031AD75 /* ReplyView.swift */, - 41A1994729D325800031AD75 /* SwipeableView.swift */, ); path = SharedViews; sourceTree = ""; @@ -3533,6 +3547,7 @@ 3A4193912A580C85006A6B22 /* RichTransactionReactService.swift in Sources */, 93FC169B2B0197FD0062B507 /* BtcApiService.swift in Sources */, 3AA2D5F7280EADE3000ED971 /* SocketService.swift in Sources */, + 9380EF632D1119DD006939E1 /* ChatSwipeWrapper.swift in Sources */, E95F85802008C8D70070534A /* ChatListFactory.swift in Sources */, 41A1994429D2D3CF0031AD75 /* MessageModel.swift in Sources */, 93775E462A674FA9009061AC /* Markdown+Adamant.swift in Sources */, @@ -3595,13 +3610,13 @@ 4186B3302941E642006594A3 /* AdmWalletService+DynamicConstants.swift in Sources */, E95F85852008CB3A0070534A /* ChatListViewController.swift in Sources */, 3AE0A42E2BC6A96B00BF7125 /* IPFS+Constants.swift in Sources */, + 9380EF682D112BB9006939E1 /* ChatSwipeManager.swift in Sources */, E9FEECA62143C300007DD7C8 /* EthWalletService+RichMessageProvider.swift in Sources */, 6403F5E622723FDA00D58779 /* DashWalletViewController.swift in Sources */, 931224AF2C7AA88E009E0ED0 /* InfoService.swift in Sources */, 93C7944E2B077C1F00408826 /* DashSendRawTransactionDTO.swift in Sources */, 4193AE1629FBEFBF002F21BE /* NSAttributedText+Adamant.swift in Sources */, 931224AB2C7AA212009E0ED0 /* InfoServiceRatesRequestDTO.swift in Sources */, - 41A1994829D325800031AD75 /* SwipeableView.swift in Sources */, 4164A9D928F17DA700EEF16D /* AdamantChatTransactionService.swift in Sources */, E993302221354BC300CD5200 /* EthWalletFactory.swift in Sources */, 418FDE502A25CA340055E3CD /* ChatMenuManager.swift in Sources */, @@ -3693,6 +3708,7 @@ 3AA2D5FA280EAF5D000ED971 /* AdamantSocketService.swift in Sources */, 649D6BEC21BD5A53009E727B /* UISuffixTextField.swift in Sources */, E93B0D762028B28E00126346 /* AdamantChatsProvider.swift in Sources */, + 9380EF662D111BD1006939E1 /* ChatSwipeWrapperModel.swift in Sources */, 3A33F9FA2A7A53DA002B8003 /* EmojiUpdateType.swift in Sources */, 934FD9A82C783E0C00336841 /* InfoServiceMapper.swift in Sources */, 936658932B0AC03700BDB2D3 /* CoinsNodesListStrings.swift in Sources */, diff --git a/Adamant/Modules/Chat/ChatFactory.swift b/Adamant/Modules/Chat/ChatFactory.swift index 1c715a240..32debf2c8 100644 --- a/Adamant/Modules/Chat/ChatFactory.swift +++ b/Adamant/Modules/Chat/ChatFactory.swift @@ -79,6 +79,7 @@ struct ChatFactory { storedObjects: delegates.asArray + [dialogManager], admWalletService: walletService, screensFactory: screensFactory, + chatSwipeManager: .init(viewModel: viewModel), sendTransaction: makeSendTransactionAction( viewModel: viewModel, screensFactory: screensFactory diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index a9ce511f1..b309fc4d0 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -31,6 +31,7 @@ final class ChatViewController: MessagesViewController { private let walletServiceCompose: WalletServiceCompose private let admWalletService: WalletService? private let screensFactory: ScreensFactory + private let chatSwipeManager: ChatSwipeManager let viewModel: ChatViewModel @@ -90,6 +91,7 @@ final class ChatViewController: MessagesViewController { storedObjects: [AnyObject], admWalletService: WalletService?, screensFactory: ScreensFactory, + chatSwipeManager: ChatSwipeManager, sendTransaction: @escaping SendTransaction ) { self.viewModel = viewModel @@ -98,6 +100,7 @@ final class ChatViewController: MessagesViewController { self.admWalletService = admWalletService self.screensFactory = screensFactory self.sendTransaction = sendTransaction + self.chatSwipeManager = chatSwipeManager super.init(nibName: nil, bundle: nil) inputBar.onAttachmentButtonTap = { [weak self] in @@ -132,6 +135,7 @@ final class ChatViewController: MessagesViewController { configureDropFiles() setupObservers() viewModel.loadFirstMessagesIfNeeded() + chatSwipeManager.configure(chatView: view) } override func viewWillLayoutSubviews() { @@ -237,17 +241,6 @@ extension ChatViewController { let velocity = panGesture.velocity(in: messagesCollectionView) return abs(velocity.x) > abs(velocity.y) } - - private func swipeStateAction(_ state: SwipeableView.State) { - if state == .began { - chatMessagesCollectionView.stopDecelerating() - messagesCollectionView.isScrollEnabled = false - } - - if state == .ended { - messagesCollectionView.isScrollEnabled = true - } - } } // MARK: Delegate Protocols @@ -412,8 +405,8 @@ private extension ChatViewController { } .store(in: &subscriptions) - viewModel.$swipeState - .sink { [weak self] in self?.swipeStateAction($0) } + viewModel.enableScroll + .sink { [weak self] in self?.enableScroll($0) } .store(in: &subscriptions) viewModel.$isNeedToAnimateScroll @@ -790,6 +783,15 @@ private extension ChatViewController { } } + func enableScroll(_ isEnabled: Bool) { + if isEnabled { + chatMessagesCollectionView.isScrollEnabled = true + } else { + chatMessagesCollectionView.stopDecelerating() + chatMessagesCollectionView.isScrollEnabled = false + } + } + func dismissTransferViewController( andPresent viewController: UIViewController?, didFinishWithTransfer: TransactionDetails? diff --git a/Adamant/Modules/Chat/View/Managers/ChatAction.swift b/Adamant/Modules/Chat/View/Managers/ChatAction.swift index cb997b25d..b4fef84a4 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatAction.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatAction.swift @@ -12,9 +12,8 @@ import CommonKit enum ChatAction { case forceUpdateTransactionStatus(id: String) case openTransactionDetails(id: String) - case reply(message: MessageModel) + case reply(id: String) case scrollTo(message: ChatMessageReplyCell.Model) - case swipeState(state: SwipeableView.State) case copy(text: String) case copyInPart(text:String) case report(id: String) diff --git a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift index 0be66b011..5d6fd69e1 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift @@ -141,10 +141,10 @@ final class ChatDataSourceManager: MessagesDataSource { return model.value } - cell.transactionView.actionHandler = { [weak self] in self?.handleAction($0) } - cell.transactionView.chatMessagesListViewModel = viewModel.chatMessagesListViewModel - cell.transactionView.model = model.value - cell.transactionView.setSubscription(publisher: publisher, collection: messagesCollectionView) + cell.actionHandler = { [weak self] in self?.handleAction($0) } + cell.chatMessagesListViewModel = viewModel.chatMessagesListViewModel + cell.model = model.value + cell.setSubscription(publisher: publisher, collection: messagesCollectionView) cell.configure(with: message, at: indexPath, and: messagesCollectionView) return cell } @@ -163,10 +163,10 @@ final class ChatDataSourceManager: MessagesDataSource { return model.value } - cell.containerMediaView.actionHandler = { [weak self] in self?.handleAction($0) } - cell.containerMediaView.chatMessagesListViewModel = viewModel.chatMessagesListViewModel - cell.containerMediaView.model = model.value - cell.containerMediaView.setSubscription(publisher: publisher, collection: messagesCollectionView) + cell.actionHandler = { [weak self] in self?.handleAction($0) } + cell.chatMessagesListViewModel = viewModel.chatMessagesListViewModel + cell.model = model.value + cell.setSubscription(publisher: publisher, collection: messagesCollectionView) cell.configure(with: message, at: indexPath, and: messagesCollectionView) return cell } @@ -183,12 +183,10 @@ private extension ChatDataSourceManager { viewModel.didTapTransfer.send(id) case let .forceUpdateTransactionStatus(id): viewModel.forceUpdateTransactionStatus(id: id) - case let .reply(message): - viewModel.replyMessageIfNeeded(message) + case let .reply(id): + viewModel.replyMessageIfNeeded(id: id) case let .scrollTo(message): viewModel.scroll(to: message) - case let .swipeState(state): - viewModel.swipeState = state case let .copy(text): viewModel.copyMessageAction(text) case let .remove(id): diff --git a/Adamant/Modules/Chat/View/Managers/ChatSwipeManager.swift b/Adamant/Modules/Chat/View/Managers/ChatSwipeManager.swift new file mode 100644 index 000000000..833c44110 --- /dev/null +++ b/Adamant/Modules/Chat/View/Managers/ChatSwipeManager.swift @@ -0,0 +1,110 @@ +// +// ChatSwipeManager.swift +// Adamant +// +// Created by Andrew G on 17.12.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit + +@MainActor +final class ChatSwipeManager: NSObject { + private let viewModel: ChatViewModel + private var chatView: UIView? + private var vibrated = false + + private var requiredSwipeOffset: CGFloat { + -UIScreen.main.bounds.size.width * 0.05 + } + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + super.init() + } + + func configure(chatView: UIView) { + let recognizer = UIPanGestureRecognizer( + target: self, + action: #selector(onSwipe(_:)) + ) + + recognizer.delegate = self + chatView.addGestureRecognizer(recognizer) + self.chatView = chatView + } +} + +extension ChatSwipeManager: UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin( + _ recognizer: UIGestureRecognizer + ) -> Bool { + guard let recognizer = recognizer as? UIPanGestureRecognizer else { + return false + } + + let velocity = recognizer.velocity(in: chatView) + guard abs(velocity.x) > abs(velocity.y) else { return false } + + let location = recognizer.location(in: chatView) + guard let id = findChatSwipeWrapperId(location) else { return false } + + viewModel.updateSwipeableId(id) + return true + } + + func gestureRecognizer( + _: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer + ) -> Bool { true } +} + +private extension ChatSwipeManager { + func findChatSwipeWrapperId(_ location: CGPoint) -> String? { + var view = chatView?.hitTest(location, with: nil) + + while view != nil { + if let swipeWrapper = view as? ChatSwipeWrapper { + return swipeWrapper.model.id + } else { + view = view?.superview + } + } + + return nil + } + + @objc func onSwipe(_ recognizer: UIPanGestureRecognizer) { + let translation = recognizer.translation(in: chatView) + let offset = translation.x <= .zero + ? translation.x + : .zero + + switch recognizer.state { + case .possible: + break + case .began: + vibrated = false + viewModel.enableScroll.send(false) + case .changed: + viewModel.updateSwipingOffset(offset) + + if offset > requiredSwipeOffset { + vibrated = false + } + + guard !vibrated else { break } + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + vibrated = true + case .ended, .cancelled, .failed: + if offset <= requiredSwipeOffset { + viewModel.replyMessageIfNeeded(id: viewModel.swipeableMessage.id) + } + + viewModel.updateSwipeableId(nil) + viewModel.enableScroll.send(true) + @unknown default: + break + } + } +} diff --git a/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell+Model.swift index b645fe701..8a2830a19 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell+Model.swift @@ -19,6 +19,7 @@ extension ChatMessageCell { let opponentAddress: String let isFake: Bool var isHidden: Bool + var swipeState: ChatSwipeWrapperModel.State static var `default`: Self { Self( @@ -30,7 +31,8 @@ extension ChatMessageCell { address: "", opponentAddress: "", isFake: false, - isHidden: false + isHidden: false, + swipeState: .idle ) } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell.swift index 6e6d89c8a..e6f493956 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell.swift @@ -21,11 +21,6 @@ final class ChatMessageCell: TextMessageCell, ChatModelView { // MARK: Proprieties - private lazy var swipeView: SwipeableView = { - let view = SwipeableView(frame: .zero, view: contentView, xPadding: 8) - return view - }() - private lazy var reactionsContanerView: UIStackView = { let stack = UIStackView(arrangedSubviews: [ownReactionLabel, opponentReactionLabel]) stack.distribution = .fillProportionally @@ -34,6 +29,9 @@ final class ChatMessageCell: TextMessageCell, ChatModelView { return stack }() + private lazy var swipeWrapper = ChatSwipeWrapper(cellContainerView) + private lazy var cellContainerView = UIView() + private lazy var ownReactionLabel: UILabel = { let label = UILabel() label.text = getReaction(for: model.address) @@ -79,7 +77,7 @@ final class ChatMessageCell: TextMessageCell, ChatModelView { var model: Model = .default { didSet { guard model != oldValue else { return } - + swipeWrapper.model = .init(id: model.id, state: model.swipeState) containerView.isHidden = model.isHidden reactionsContanerView.isHidden = model.reactions == nil ownReactionLabel.isHidden = getReaction(for: model.address) == nil @@ -87,10 +85,6 @@ final class ChatMessageCell: TextMessageCell, ChatModelView { updateOwnReaction() updateOpponentReaction() layoutReactionLabel() - - swipeView.didSwipeAction = { [actionHandler, model] in - actionHandler(.reply(message: model)) - } } } @@ -143,27 +137,28 @@ final class ChatMessageCell: TextMessageCell, ChatModelView { } override func setupSubviews() { - super.setupSubviews() - - contentView.addSubview(swipeView) - swipeView.snp.makeConstraints { make in - make.leading.trailing.bottom.top.equalToSuperview() - } - - swipeView.swipeStateAction = { [actionHandler] state in - actionHandler(.swipeState(state: state)) - } + contentView.addSubview(swipeWrapper) + cellContainerView.addSubviews( + accessoryView, + cellTopLabel, + messageTopLabel, + messageBottomLabel, + cellBottomLabel, + messageContainerView, + avatarView, + messageTimestampLabel + ) + messageContainerView.addSubview(messageLabel) configureMenu() - - contentView.addSubview(reactionsContanerView) + cellContainerView.addSubview(reactionsContanerView) } func configureMenu() { containerView.layer.cornerRadius = 10 messageContainerView.removeFromSuperview() - contentView.addSubview(containerView) + cellContainerView.addSubview(containerView) containerView.addSubview(messageContainerView) @@ -356,11 +351,11 @@ final class ChatMessageCell: TextMessageCell, ChatModelView { : minReactionsSpacingToOwnBoundary if model.isFromCurrentSender { - x = min(x, contentView.bounds.width - minSpace) + x = min(x, cellContainerView.bounds.width - minSpace) x = max(x, minReactionsSpacingToOppositeBoundary) } else { x = max(x, minSpace) - x = min(x, contentView.bounds.width - minReactionsSpacingToOppositeBoundary - reactionsContanerViewWidth) + x = min(x, cellContainerView.bounds.width - minReactionsSpacingToOppositeBoundary - reactionsContanerViewWidth) } reactionsContanerView.frame = CGRect( @@ -418,6 +413,11 @@ final class ChatMessageCell: TextMessageCell, ChatModelView { delegate?.didTapBackground(in: self) } } + + override func layoutSubviews() { + super.layoutSubviews() + swipeWrapper.frame = contentView.bounds + } } extension ChatMessageCell { @@ -441,7 +441,7 @@ extension ChatMessageCell { title: .adamant.chat.reply, systemImageName: "arrowshape.turn.up.left" ) { [actionHandler, model] in - actionHandler(.reply(message: model)) + actionHandler(.reply(id: model.id)) } let copy = AMenuItem.action( diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift index 36c570a1e..b8d03ed4f 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift @@ -9,22 +9,30 @@ import UIKit import SnapKit import MessageKit +import Combine -final class ChatMediaCell: MessageContentCell { - let containerMediaView = ChatMediaContainerView() +final class ChatMediaCell: MessageContentCell, ChatModelView { + private let containerMediaView = ChatMediaContainerView() + private let cellContainerView = UIView() + private lazy var swipeWrapper = ChatSwipeWrapper(cellContainerView) - override init(frame: CGRect) { - super.init(frame: frame) - configure() + var subscription: AnyCancellable? + + var model: ChatMediaContainerView.Model = .default { + didSet { + swipeWrapper.model = .init(id: model.id, state: model.swipeState) + containerMediaView.model = model + } } - required init?(coder: NSCoder) { - super.init(coder: coder) - configure() + var actionHandler: (ChatAction) -> Void { + get { containerMediaView.actionHandler } + set { containerMediaView.actionHandler = newValue } } - override func prepareForReuse() { - containerMediaView.prepareForReuse() + var chatMessagesListViewModel: ChatMessagesListViewModel? { + get { containerMediaView.chatMessagesListViewModel } + set { containerMediaView.chatMessagesListViewModel = newValue } } override var isSelected: Bool { @@ -33,6 +41,16 @@ final class ChatMediaCell: MessageContentCell { } } + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + override func configure( with message: MessageType, at indexPath: IndexPath, @@ -54,13 +72,27 @@ final class ChatMediaCell: MessageContentCell { make.height.equalTo(messageContainerView.frame.height) } } + + override func setupSubviews() { + cellContainerView.addSubviews( + accessoryView, + cellTopLabel, + messageTopLabel, + messageBottomLabel, + cellBottomLabel, + messageContainerView, + avatarView, + messageTimestampLabel, + containerMediaView + ) + } } private extension ChatMediaCell { func configure() { - contentView.addSubview(containerMediaView) - containerMediaView.snp.makeConstraints { make in - make.directionalEdges.equalToSuperview() + contentView.addSubview(swipeWrapper) + swipeWrapper.snp.makeConstraints { + $0.directionalEdges.equalToSuperview() } } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift index ab74dee3b..6b0b8fe67 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift @@ -19,6 +19,7 @@ extension ChatMediaContainerView { let opponentAddress: String let txStatus: MessageStatus var status: FileMessageStatus + var swipeState: ChatSwipeWrapperModel.State static var `default`: Self { Self( @@ -29,7 +30,8 @@ extension ChatMediaContainerView { address: "", opponentAddress: "", txStatus: .failed, - status: .failed + status: .failed, + swipeState: .idle ) } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 61301e2ad..7f0ccfa6d 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -10,12 +10,7 @@ import UIKit import Combine import CommonKit -final class ChatMediaContainerView: UIView, ChatModelView { - private lazy var swipeView: SwipeableView = { - let view = SwipeableView(frame: .zero, view: self) - return view - }() - +final class ChatMediaContainerView: UIView { private let spacingView: UIView = { let view = UIView() view.setContentCompressionResistancePriority(.dragThatCanResizeScene, for: .horizontal) @@ -101,10 +96,6 @@ final class ChatMediaContainerView: UIView, ChatModelView { var chatMessagesListViewModel: ChatMessagesListViewModel? - // MARK: Proprieties - - var subscription: AnyCancellable? - var model: Model = .default { didSet { update() } } @@ -153,35 +144,20 @@ final class ChatMediaContainerView: UIView, ChatModelView { } extension ChatMediaContainerView { - func configure() { - addSubview(swipeView) - swipeView.snp.makeConstraints { make in - make.directionalEdges.equalToSuperview() - } - + func configure() { addSubview(horizontalStack) horizontalStack.snp.makeConstraints { $0.verticalEdges.equalToSuperview() $0.horizontalEdges.equalToSuperview().inset(4) } - swipeView.swipeStateAction = { [actionHandler] state in - actionHandler(.swipeState(state: state)) - } - reactionsStack.snp.makeConstraints { $0.width.equalTo(reactionsWidth) } chatMenuManager.setup(for: contentView) } func update() { contentView.model = model.content - - swipeView.didSwipeAction = { [actionHandler, model] in - actionHandler(.reply(message: model)) - } - updateLayout() - ownReactionLabel.isHidden = getReaction(for: model.address) == nil opponentReactionLabel.isHidden = getReaction(for: model.opponentAddress) == nil updateOwnReaction() @@ -313,7 +289,7 @@ extension ChatMediaContainerView { title: .adamant.chat.reply, systemImageName: "arrowshape.turn.up.left" ) { [actionHandler, model] in - actionHandler(.reply(message: model)) + actionHandler(.reply(id: model.id)) } let copy = AMenuItem.action( diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index 5eb6059f6..0450da47e 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -242,7 +242,8 @@ private extension ChatMediaContentView { reactions: nil, address: .empty, opponentAddress: .empty, - isHidden: false + isHidden: false, + swipeState: .idle ))) } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatModelView.swift b/Adamant/Modules/Chat/View/Subviews/ChatModelView.swift index 92989660e..a4f868b17 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatModelView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatModelView.swift @@ -35,9 +35,4 @@ extension ChatModelView { collection?.collectionViewLayout.invalidateLayout() } } - - func prepareForReuse() { - model = .default - subscription = nil - } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell+Model.swift index 16453508a..0f41b7987 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell+Model.swift @@ -20,6 +20,7 @@ extension ChatMessageReplyCell { let address: String let opponentAddress: String var isHidden: Bool + var swipeState: ChatSwipeWrapperModel.State static var `default`: Self { Self( @@ -32,7 +33,8 @@ extension ChatMessageReplyCell { reactions: nil, address: "", opponentAddress: "", - isHidden: false + isHidden: false, + swipeState: .idle ) } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell.swift index f5ebe540d..6fd94484f 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell.swift @@ -25,10 +25,8 @@ final class ChatMessageReplyCell: MessageContentCell, ChatModelView { private var messageLabel = MessageLabel() private var replyMessageLabel = UILabel() - private lazy var swipeView: SwipeableView = { - let view = SwipeableView(frame: .zero, view: contentView, xPadding: 8) - return view - }() + private(set) lazy var swipeWrapper = ChatSwipeWrapper(cellContainerView) + private lazy var cellContainerView = UIView() static let replyViewHeight: CGFloat = 25 @@ -132,7 +130,7 @@ final class ChatMessageReplyCell: MessageContentCell, ChatModelView { var model: Model = .default { didSet { guard model != oldValue else { return } - + swipeWrapper.model = .init(id: model.id, state: model.swipeState) containerView.isHidden = model.isHidden replyMessageLabel.attributedText = model.messageReply @@ -149,10 +147,6 @@ final class ChatMessageReplyCell: MessageContentCell, ChatModelView { updateOwnReaction() updateOpponentReaction() layoutReactionLabel() - - swipeView.didSwipeAction = { [actionHandler, model] in - actionHandler(.reply(message: model)) - } } } @@ -210,16 +204,17 @@ final class ChatMessageReplyCell: MessageContentCell, ChatModelView { } override func setupSubviews() { - super.setupSubviews() - - contentView.addSubview(swipeView) - swipeView.snp.makeConstraints { make in - make.leading.trailing.bottom.top.equalToSuperview() - } - - swipeView.swipeStateAction = { [actionHandler] state in - actionHandler(.swipeState(state: state)) - } + contentView.addSubview(swipeWrapper) + cellContainerView.addSubviews( + accessoryView, + cellTopLabel, + messageTopLabel, + messageBottomLabel, + cellBottomLabel, + messageContainerView, + avatarView, + messageTimestampLabel + ) messageContainerView.addSubview(verticalStack) messageLabel.numberOfLines = 0 @@ -235,14 +230,14 @@ final class ChatMessageReplyCell: MessageContentCell, ChatModelView { configureMenu() - contentView.addSubview(reactionsContanerView) + cellContainerView.addSubview(reactionsContanerView) } func configureMenu() { containerView.layer.cornerRadius = 10 messageContainerView.removeFromSuperview() - contentView.addSubview(containerView) + cellContainerView.addSubview(containerView) containerView.addSubview(messageContainerView) chatMenuManager.setup(for: containerView) @@ -434,11 +429,11 @@ final class ChatMessageReplyCell: MessageContentCell, ChatModelView { : minReactionsSpacingToOwnBoundary if model.isFromCurrentSender { - x = min(x, contentView.bounds.width - minSpace) + x = min(x, cellContainerView.bounds.width - minSpace) x = max(x, minReactionsSpacingToOppositeBoundary) } else { x = max(x, minSpace) - x = min(x, contentView.bounds.width - minReactionsSpacingToOppositeBoundary - reactionsContanerViewWidth) + x = min(x, cellContainerView.bounds.width - minReactionsSpacingToOppositeBoundary - reactionsContanerViewWidth) } reactionsContanerView.frame = CGRect( @@ -516,6 +511,11 @@ final class ChatMessageReplyCell: MessageContentCell, ChatModelView { delegate?.didTapBackground(in: self) } } + + override func layoutSubviews() { + super.layoutSubviews() + swipeWrapper.frame = contentView.bounds + } } extension ChatMessageReplyCell { @@ -539,7 +539,7 @@ extension ChatMessageReplyCell { title: .adamant.chat.reply, systemImageName: "arrowshape.turn.up.left" ) { [actionHandler, model] in - actionHandler(.reply(message: model)) + actionHandler(.reply(id: model.id)) } let copy = AMenuItem.action( diff --git a/Adamant/Modules/Chat/View/Subviews/ChatSwipeWrapper/ChatSwipeWrapper.swift b/Adamant/Modules/Chat/View/Subviews/ChatSwipeWrapper/ChatSwipeWrapper.swift new file mode 100644 index 000000000..0ebf182c3 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatSwipeWrapper/ChatSwipeWrapper.swift @@ -0,0 +1,58 @@ +// +// ChatSwipeWrapper.swift +// Adamant +// +// Created by Andrew G on 16.12.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import SnapKit + +final class ChatSwipeWrapper: UIView { + var model: ChatSwipeWrapperModel = .default { + didSet { update(old: oldValue) } + } + + let wrappedView: View + + init(_ wrappedView: View) { + self.wrappedView = wrappedView + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension ChatSwipeWrapper { + func configure() { + addSubview(wrappedView) + wrappedView.snp.makeConstraints { + $0.directionalVerticalEdges.width.leading.equalToSuperview() + } + } + + func update(old: ChatSwipeWrapperModel) { + guard old.state != model.state else { return } + + wrappedView.snp.updateConstraints { + $0.leading.equalToSuperview().offset(model.state.value) + } + + guard old.id == model.id else { return } + + switch model.state { + case .idle: + UIView.animate( + withDuration: 0.25, + delay: .zero, + options: .curveEaseOut + ) { [weak self] in self?.layoutIfNeeded() } + case .offset: + break + } + } +} diff --git a/Adamant/Modules/Chat/View/Subviews/ChatSwipeWrapper/ChatSwipeWrapperModel.swift b/Adamant/Modules/Chat/View/Subviews/ChatSwipeWrapper/ChatSwipeWrapperModel.swift new file mode 100644 index 000000000..95ebf67b4 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatSwipeWrapper/ChatSwipeWrapperModel.swift @@ -0,0 +1,34 @@ +// +// ChatSwipeWrapperModel.swift +// Adamant +// +// Created by Andrew G on 16.12.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import CoreGraphics + +struct ChatSwipeWrapperModel: Identifiable, Equatable { + let id: String + var state: State + + static let `default` = Self(id: .empty, state: .idle) +} + +extension ChatSwipeWrapperModel { + enum State: Equatable { + case idle + case offset(CGFloat) + } +} + +extension ChatSwipeWrapperModel.State { + var value: CGFloat { + switch self { + case .idle: + .zero + case let .offset(value): + value + } + } +} diff --git a/Adamant/Modules/Chat/View/Subviews/ChatTransaction/ChatTransactionCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatTransaction/ChatTransactionCell.swift index 291ea39d7..b0d0c29fa 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatTransaction/ChatTransactionCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatTransaction/ChatTransactionCell.swift @@ -9,9 +9,31 @@ import UIKit import SnapKit import MessageKit +import Combine -final class ChatTransactionCell: MessageContentCell { - let transactionView = ChatTransactionContainerView() +final class ChatTransactionCell: MessageContentCell, ChatModelView { + private let transactionView = ChatTransactionContainerView() + private let cellContainerView = UIView() + private lazy var swipeWrapper = ChatSwipeWrapper(cellContainerView) + + var subscription: AnyCancellable? + + var model: ChatTransactionContainerView.Model = .default { + didSet { + swipeWrapper.model = .init(id: model.id, state: model.swipeState) + transactionView.model = model + } + } + + var actionHandler: (ChatAction) -> Void { + get { transactionView.actionHandler } + set { transactionView.actionHandler = newValue } + } + + var chatMessagesListViewModel: ChatMessagesListViewModel? { + get { transactionView.chatMessagesListViewModel } + set { transactionView.chatMessagesListViewModel = newValue } + } override init(frame: CGRect) { super.init(frame: frame) @@ -50,11 +72,27 @@ final class ChatTransactionCell: MessageContentCell { transactionView.frame = messageContainerView.frame transactionView.layoutIfNeeded() } + + override func setupSubviews() { + cellContainerView.addSubviews( + accessoryView, + cellTopLabel, + messageTopLabel, + messageBottomLabel, + cellBottomLabel, + messageContainerView, + avatarView, + messageTimestampLabel, + transactionView + ) + } } private extension ChatTransactionCell { func configure() { - contentView.addSubview(transactionView) - transactionView.frame = messageContainerView.frame + contentView.addSubview(swipeWrapper) + swipeWrapper.snp.makeConstraints { + $0.directionalEdges.equalToSuperview() + } } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView+Model.swift index 71481402d..043a8911a 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView+Model.swift @@ -17,6 +17,7 @@ extension ChatTransactionContainerView { let reactions: Set? let address: String let opponentAddress: String + var swipeState: ChatSwipeWrapperModel.State static var `default`: Self { Self( @@ -26,7 +27,8 @@ extension ChatTransactionContainerView { status: .notInitiated, reactions: nil, address: "", - opponentAddress: "" + opponentAddress: "", + swipeState: .idle ) } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView.swift index a819e61a4..68513c97e 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView.swift @@ -13,15 +13,13 @@ import SwiftUI import AdvancedContextMenuKit import CommonKit -final class ChatTransactionContainerView: UIView, ChatModelView { +final class ChatTransactionContainerView: UIView { // MARK: Dependencies var chatMessagesListViewModel: ChatMessagesListViewModel? // MARK: Proprieties - var subscription: AnyCancellable? - var model: Model = .default { didSet { update() } } @@ -68,11 +66,6 @@ final class ChatTransactionContainerView: UIView, ChatModelView { return stack }() - private lazy var swipeView: SwipeableView = { - let view = SwipeableView(frame: .zero, view: self) - return view - }() - private lazy var ownReactionLabel: UILabel = { let label = UILabel() label.text = getReaction(for: model.address) @@ -153,21 +146,12 @@ extension ChatTransactionContainerView: ReusableView { private extension ChatTransactionContainerView { func configure() { - addSubview(swipeView) - swipeView.snp.makeConstraints { make in - make.directionalEdges.equalToSuperview() - } - addSubview(horizontalStack) horizontalStack.snp.makeConstraints { $0.top.bottom.equalToSuperview() $0.horizontalEdges.equalToSuperview() } - swipeView.swipeStateAction = { [actionHandler] state in - actionHandler(.swipeState(state: state)) - } - chatMenuManager.setup(for: contentView) } @@ -180,10 +164,6 @@ private extension ChatTransactionContainerView { opponentReactionLabel.isHidden = getReaction(for: model.opponentAddress) == nil updateOwnReaction() updateOpponentReaction() - - swipeView.didSwipeAction = { [actionHandler, model] in - actionHandler(.reply(message: model)) - } } func updateStatus(_ status: TransactionStatus) { @@ -309,7 +289,7 @@ extension ChatTransactionContainerView { title: .adamant.chat.reply, systemImageName: "arrowshape.turn.up.left" ) { [actionHandler, model] in - actionHandler(.reply(message: model)) + actionHandler(.reply(id: model.id)) } return AMenuSection([reply, report, remove]) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Content/ChatTransactionContentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Content/ChatTransactionContentView.swift index 2917ba040..d2f824e18 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Content/ChatTransactionContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Content/ChatTransactionContentView.swift @@ -239,7 +239,8 @@ private extension ChatTransactionContentView { reactions: nil, address: "", opponentAddress: "", - isHidden: false + isHidden: false, + swipeState: .idle ))) return } diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index b0fab14f6..24c5f1455 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -195,7 +195,8 @@ private extension ChatMessageFactory { address: address, opponentAddress: opponentAddress, isFake: transaction.isFake, - isHidden: false + isHidden: false, + swipeState: .idle ) )) } ?? .default @@ -236,7 +237,8 @@ private extension ChatMessageFactory { reactions: reactions, address: address, opponentAddress: opponentAddress, - isHidden: false + isHidden: false, + swipeState: .idle ) )) } @@ -287,7 +289,8 @@ private extension ChatMessageFactory { status: transaction.transactionStatus ?? .notInitiated, reactions: reactions, address: address, - opponentAddress: opponentAddress + opponentAddress: opponentAddress, + swipeState: .idle ))) } @@ -354,7 +357,8 @@ private extension ChatMessageFactory { address: address, opponentAddress: opponentAddress, txStatus: transaction.statusEnum, - status: .failed + status: .failed, + swipeState: .idle ))) } @@ -448,7 +452,8 @@ private extension ChatMessageFactory { status: transaction.statusEnum.toTransactionStatus(), reactions: reactions, address: address, - opponentAddress: opponentAddress + opponentAddress: opponentAddress, + swipeState: .idle ))) } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 23665006e..612dccc74 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -98,7 +98,9 @@ final class ChatViewModel: NSObject { let presentDocumentPickerVC = ObservableSender() let presentDocumentViewerVC = ObservableSender<([FileResult], Int)>() let presentDropView = ObservableSender() + let enableScroll = ObservableSender() + @ObservableValue private(set) var swipeableMessage: ChatSwipeWrapperModel = .default @ObservableValue private(set) var isHeaderLoading = false @ObservableValue private(set) var fullscreenLoading = false @ObservableValue private(set) var messages = [ChatMessage]() @@ -110,7 +112,6 @@ final class ChatViewModel: NSObject { @ObservableValue private(set) var isNeedToAnimateScroll = false @ObservableValue private(set) var dateHeader: String? @ObservableValue private(set) var dateHeaderHidden: Bool = true - @ObservableValue var swipeState: SwipeableView.State = .ended @ObservableValue var inputText = "" @ObservableValue var replyMessage: MessageModel? @ObservableValue var scrollToMessage: (toId: String?, fromId: String?) @@ -553,11 +554,11 @@ final class ChatViewModel: NSObject { } } - func replyMessageIfNeeded(_ messageModel: MessageModel?) { - let tx = chatTransactions.first(where: { $0.txId == messageModel?.id }) + func replyMessageIfNeeded(id: String) { + let tx = chatTransactions.first(where: { $0.txId == id }) guard isSendingAvailable, tx?.isFake == false else { return } - let message = messages.first(where: { $0.messageId == messageModel?.id }) + let message = messages.first(where: { $0.messageId == id }) guard message?.status != .failed else { dialog.send(.warning(String.adamant.reply.failedMessageError)) return @@ -568,7 +569,7 @@ final class ChatViewModel: NSObject { return } - replyMessage = messageModel + replyMessage = message?.messageModel } func animateScrollIfNeeded(to messageIndex: Int, visibleIndex: Int?) { @@ -962,6 +963,14 @@ final class ChatViewModel: NSObject { ) } } + + func updateSwipeableId(_ id: String?) { + swipeableMessage = id.map { .init(id: $0, state: .idle) } ?? .default + } + + func updateSwipingOffset(_ offset: CGFloat) { + swipeableMessage.state = .offset(offset) + } } extension ChatViewModel { @@ -1114,6 +1123,14 @@ private extension ChatViewModel { } .store(in: &subscriptions) + $swipeableMessage + .removeDuplicates() + .sink { [weak self] _ in + guard let self else { return } + updateSwipeStates(messages: &messages) + } + .store(in: &subscriptions) + NotificationCenter.default .notifications(named: .AdamantVisibleWalletsService.visibleWallets) .sink { @MainActor [weak self] _ in self?.updateAttachmentButtonAvailability() } @@ -1226,7 +1243,7 @@ private extension ChatViewModel { postProcess(messages: &messages) - await setupNewMessages( + setupNewMessages( newMessages: messages, resetLoadingProperty: resetLoadingProperty, expirationTimestamp: expirationTimestamp @@ -1254,6 +1271,7 @@ private extension ChatViewModel { } updateAutoDownloadWarning(messages: &messages) + updateSwipeStates(messages: &messages) } func autoDownloadPolicyChanged() { @@ -1285,6 +1303,14 @@ private extension ChatViewModel { } } + func updateSwipeStates(messages: inout [ChatMessage]) { + messages.indices.forEach { + messages[$0].swipeState = messages[$0].id == swipeableMessage.id + ? swipeableMessage.state + : .idle + } + } + func showAutoDownloadWarning( files: [ChatFile], isFromCurrentSender: Bool @@ -1340,7 +1366,7 @@ private extension ChatViewModel { newMessages: [ChatMessage], resetLoadingProperty: Bool, expirationTimestamp: TimeInterval? - ) async { + ) { var newMessages = newMessages updateHiddenMessage(&newMessages) @@ -1644,6 +1670,19 @@ private extension ChatViewModel { } private extension ChatMessage { + var messageModel: MessageModel { + switch content { + case let .message(model): + return model.value + case let .reply(model): + return model.value + case let .transaction(model): + return model.value + case let .file(model): + return model.value + } + } + var isFromCurrentSender: Bool { switch content { case let .message(model): @@ -1693,6 +1732,42 @@ private extension ChatMessage { } } + var swipeState: ChatSwipeWrapperModel.State { + get { + switch content { + case let .message(model): + return model.value.swipeState + case let .reply(model): + return model.value.swipeState + case let .transaction(model): + return model.value.swipeState + case let .file(model): + return model.value.swipeState + } + } + + set { + switch content { + case let .message(model): + var model = model.value + model.swipeState = newValue + content = .message(.init(value: model)) + case let .reply(model): + var model = model.value + model.swipeState = newValue + content = .reply(.init(value: model)) + case let .transaction(model): + var model = model.value + model.swipeState = newValue + content = .transaction(.init(value: model)) + case let .file(model): + var model = model.value + model.swipeState = newValue + content = .file(.init(value: model)) + } + } + } + func getFiles() -> [ChatFile] { guard case let .file(model) = content else { return [] } return model.value.content.fileModel.files diff --git a/Adamant/SharedViews/SwipeableView.swift b/Adamant/SharedViews/SwipeableView.swift deleted file mode 100644 index d74db0338..000000000 --- a/Adamant/SharedViews/SwipeableView.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// SwipeableView.swift -// Adamant -// -// Created by Stanislav Jelezoglo on 28.03.2023. -// Copyright © 2023 Adamant. All rights reserved. -// - -import UIKit -import SnapKit - -final class SwipeableView: UIView { - - // MARK: Proprieties - - weak var viewForSwipe: UIView? - - private var panGestureRecognizer: SwipePanGestureRecognizer? - private var xPadding: CGFloat = 0 - private var isSwipedEnough: Bool = false - private var isNeedToVibrate: Bool = true - private var oldContentOffset: CGPoint? - - private var maxSwipeValue: Double { - UIScreen.main.bounds.size.width * 0.05 - } - - var didSwipeAction: (() -> Void)? - var swipeStateAction: ((SwipeableView.State) -> Void)? - - // MARK: Init - - override init(frame: CGRect) { - super.init(frame: frame) - viewForSwipe = self - setup() - } - - init(frame: CGRect, view: UIView, xPadding: CGFloat = 0) { - super.init(frame: frame) - self.xPadding = xPadding - viewForSwipe = view - setup() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setup() - } - - // MARK: Setup - - private func setup() { - panGestureRecognizer = SwipePanGestureRecognizer( - target: self, - action: #selector(swipeGestureCellAction(_:)) - ) - viewForSwipe?.addGestureRecognizer(panGestureRecognizer!) - } -} - -// MARK: UIPanGestureRecognizer - -private extension SwipeableView { - @objc func swipeGestureCellAction(_ recognizer: UIPanGestureRecognizer) { - let translation = recognizer.translation(in: viewForSwipe) - - guard let movingView = recognizer.view?.superview as? UIView else { - return - } - - if recognizer.state == .began { - swipeStateAction?(.began) - } - - let isOnStartPosition = movingView.frame.origin.x == 0 || movingView.frame.origin.x == xPadding - - if isOnStartPosition && translation.x > 0 { - swipeStateAction?(.ended) - return - } - - if movingView.frame.origin.x <= xPadding { - movingView.center = CGPoint( - x: movingView.center.x + translation.x / 2, - y: movingView.center.y - ) - recognizer.setTranslation(CGPoint(x: 0, y: 0), in: viewForSwipe) - - if abs(movingView.frame.origin.x) > maxSwipeValue { - isSwipedEnough = true - if isNeedToVibrate { - UIImpactFeedbackGenerator(style: .heavy).impactOccurred() - } - isNeedToVibrate = false - } else { - isSwipedEnough = false - } - } - - if recognizer.state == .ended { - swipeStateAction?(.ended) - isNeedToVibrate = true - - if isSwipedEnough { - didSwipeAction?() - } - - UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) { - movingView.frame = CGRect( - x: self.xPadding, - y: movingView.frame.origin.y, - width: movingView.frame.size.width, - height: movingView.frame.size.height - ) - } - } - - if recognizer.state == .cancelled || recognizer.state == .failed { - swipeStateAction?(.ended) - } - } -} - -// MARK: State -extension SwipeableView { - enum State { - case began - case ended - } -} diff --git a/CommonKit/Sources/CommonKit/Helpers/UIHelpers/UIView+Extension.swift b/CommonKit/Sources/CommonKit/Helpers/UIHelpers/UIView+Extension.swift new file mode 100644 index 000000000..54db6da1b --- /dev/null +++ b/CommonKit/Sources/CommonKit/Helpers/UIHelpers/UIView+Extension.swift @@ -0,0 +1,14 @@ +// +// UIView+Extension.swift +// CommonKit +// +// Created by Andrew G on 17.12.2024. +// + +import UIKit + +public extension UIView { + func addSubviews(_ subviews: UIView...) { + subviews.forEach { addSubview($0) } + } +}