From b8e062c92e75e0cc8df6b77ab0e8e400688690f9 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 23 Mar 2021 18:47:21 +0800 Subject: [PATCH] feat: add poll UI/UX for compose scene --- Mastodon.xcodeproj/project.pbxproj | 25 ++- .../Diffiable/Item/CategoryPickerItem.swift | 1 + .../Diffiable/Item/ComposeStatusItem.swift | 22 ++ Mastodon/Diffiable/Item/PollItem.swift | 1 + .../Section/ComposeStatusSection.swift | 20 +- Mastodon/Diffiable/Section/PollSection.swift | 42 ++-- Mastodon/Generated/Assets.swift | 2 + .../plus.circle.imageset/Contents.json | 15 ++ .../plus.circle.imageset/plus.circle.pdf | 95 +++++++++ .../system.background.colorset/Contents.json | 12 +- .../Contents.json | 12 +- .../Contents.json | 24 ++- .../Contents.json | 38 ++++ ...mposeStatusContentCollectionViewCell.swift | 2 +- ...tatusNewPollOptionCollectionViewCell.swift | 120 +++++++++++ ...seStatusPollOptionCollectionViewCell.swift | 147 +++++++++++++ .../Scene/Compose/ComposeViewController.swift | 180 +++++++++++++++- .../Compose/ComposeViewModel+Diffable.swift | 10 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 120 +++++++---- .../Compose/View/ComposeToolbarView.swift | 40 ++-- .../Share/View/Content/PollOptionView.swift | 200 ++++++++++++++++++ .../PollOptionTableViewCell.swift | 176 +++------------ .../DeleteBackwardResponseTextField.swift | 24 +++ 23 files changed, 1077 insertions(+), 251 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json create mode 100644 Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusNewPollOptionCollectionViewCell.swift create mode 100644 Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift create mode 100644 Mastodon/Scene/Share/View/Content/PollOptionView.swift create mode 100644 Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0a3c58b9c..50f779c64 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -187,6 +187,10 @@ DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; + DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; + DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; }; + DB87D4512609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4502609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift */; }; + DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -481,6 +485,10 @@ DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentCollectionViewCell.swift; sourceTree = ""; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; + DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; + DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; + DB87D4502609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusNewPollOptionCollectionViewCell.swift; sourceTree = ""; }; + DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteBackwardResponseTextField.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = ""; }; DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -665,6 +673,7 @@ 2D152A8B25C295CC009AA50C /* StatusView.swift */, 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */, 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, + DB87D44A2609C11900D12C0D /* PollOptionView.swift */, ); path = Content; sourceTree = ""; @@ -752,7 +761,6 @@ isa = PBXGroup; children = ( DB45FB0425CA87B4005A8AC7 /* APIService */, - DB49A61925FF327D00B98345 /* EmojiService */, DB9A489B26036E19008B817C /* MastodonAttachmentService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, @@ -843,6 +851,7 @@ DB9D6C1325E4F97A0051B173 /* Container */, DBA9B90325F1D4420012E7B6 /* Control */, 2D152A8A25C295B8009AA50C /* Content */, + DB87D45C2609DE6600D12C0D /* TextField */, DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, ); @@ -1153,10 +1162,20 @@ DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */, DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */, + DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, + DB87D4502609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift */, ); path = CollectionViewCell; sourceTree = ""; }; + DB87D45C2609DE6600D12C0D /* TextField */ = { + isa = PBXGroup; + children = ( + DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */, + ); + path = TextField; + sourceTree = ""; + }; DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { isa = PBXGroup; children = ( @@ -1792,6 +1811,7 @@ DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, + DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, @@ -1818,6 +1838,7 @@ DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, + DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, @@ -1868,6 +1889,7 @@ 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, + DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, @@ -1931,6 +1953,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, + DB87D4512609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/CategoryPickerItem.swift b/Mastodon/Diffiable/Item/CategoryPickerItem.swift index 9a8f8bd6c..52bdaf39e 100644 --- a/Mastodon/Diffiable/Item/CategoryPickerItem.swift +++ b/Mastodon/Diffiable/Item/CategoryPickerItem.swift @@ -8,6 +8,7 @@ import Foundation import MastodonSDK +/// Note: update Equatable when change case enum CategoryPickerItem { case all case category(category: Mastodon.Entity.Category) diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index d49203c33..5d6e12fa2 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -9,12 +9,17 @@ import Foundation import Combine import CoreData +/// Note: update Equatable when change case enum ComposeStatusItem { case replyTo(statusObjectID: NSManagedObjectID) case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) case attachment(attachmentService: MastodonAttachmentService) + case poll(attribute: ComposePollAttribute) + case newPoll } +extension ComposeStatusItem: Equatable { } + extension ComposeStatusItem: Hashable { } extension ComposeStatusItem { @@ -38,3 +43,20 @@ extension ComposeStatusItem { } } } + +extension ComposeStatusItem { + final class ComposePollAttribute: Equatable, Hashable { + private let id = UUID() + + let option = CurrentValueSubject("") + + static func == (lhs: ComposePollAttribute, rhs: ComposePollAttribute) -> Bool { + return lhs.id == rhs.id && + lhs.option.value == rhs.option.value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift index 006400f9e..1e7bd4ce7 100644 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -8,6 +8,7 @@ import Foundation import CoreData +/// Note: update Equatable when change case enum PollItem { case opion(objectID: NSManagedObjectID, attribute: Attribute) } diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 33ef0f268..e8fee6d47 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -16,6 +16,7 @@ enum ComposeStatusSection: Equatable, Hashable { case repliedTo case status case attachment + case poll } extension ComposeStatusSection { @@ -33,7 +34,9 @@ extension ComposeStatusSection { managedObjectContext: NSManagedObjectContext, composeKind: ComposeKind, textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, - composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, + composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, + composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusNewPollOptionCollectionViewCellDelegate ) -> UICollectionViewDiffableDataSource { UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in switch item { @@ -54,11 +57,11 @@ extension ComposeStatusSection { } ComposeStatusSection.configure(cell: cell, attribute: attribute) cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate - // self size input cell cell.composeContent .removeDuplicates() .receive(on: DispatchQueue.main) .sink { text in + // self size input cell collectionView.collectionViewLayout.invalidateLayout() // bind input data attribute.composeContent.value = text @@ -124,6 +127,19 @@ extension ComposeStatusSection { } .store(in: &cell.disposeBag) return cell + case .poll(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell + cell.pollOptionView.optionTextField.text = attribute.option.value + cell.pollOption + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: attribute.option) + .store(in: &cell.disposeBag) + cell.delegate = composeStatusPollOptionCollectionViewCellDelegate + return cell + case .newPoll: + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusNewPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusNewPollOptionCollectionViewCell + cell.delegate = composeStatusNewPollOptionCollectionViewCellDelegate + return cell } } } diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 45da63bde..2f9404410 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -38,7 +38,7 @@ extension PollSection { pollOption option: PollOption, pollItemAttribute attribute: PollItem.Attribute ) { - cell.optionLabel.text = option.title + cell.pollOptionView.optionTextField.text = option.title configure(cell: cell, selectState: attribute.selectState) configure(cell: cell, voteState: attribute.voteState) cell.attribute = attribute @@ -52,35 +52,35 @@ extension PollSection { static func configure(cell: PollOptionTableViewCell, selectState state: PollItem.Attribute.SelectState) { switch state { case .none: - cell.checkmarkBackgroundView.isHidden = true - cell.checkmarkImageView.isHidden = true + cell.pollOptionView.checkmarkBackgroundView.isHidden = true + cell.pollOptionView.checkmarkImageView.isHidden = true case .off: - cell.checkmarkBackgroundView.backgroundColor = .systemBackground - cell.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor - cell.checkmarkBackgroundView.layer.borderWidth = 1 - cell.checkmarkBackgroundView.isHidden = false - cell.checkmarkImageView.isHidden = true + cell.pollOptionView.checkmarkBackgroundView.backgroundColor = .systemBackground + cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor + cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 1 + cell.pollOptionView.checkmarkBackgroundView.isHidden = false + cell.pollOptionView.checkmarkImageView.isHidden = true case .on: - cell.checkmarkBackgroundView.backgroundColor = .systemBackground - cell.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor - cell.checkmarkBackgroundView.layer.borderWidth = 0 - cell.checkmarkBackgroundView.isHidden = false - cell.checkmarkImageView.isHidden = false + cell.pollOptionView.checkmarkBackgroundView.backgroundColor = .systemBackground + cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor + cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 0 + cell.pollOptionView.checkmarkBackgroundView.isHidden = false + cell.pollOptionView.checkmarkImageView.isHidden = false } } static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) { switch state { case .hidden: - cell.optionPercentageLabel.isHidden = true - cell.voteProgressStripView.isHidden = true - cell.voteProgressStripView.setProgress(0.0, animated: false) + cell.pollOptionView.optionPercentageLabel.isHidden = true + cell.pollOptionView.voteProgressStripView.isHidden = true + cell.pollOptionView.voteProgressStripView.setProgress(0.0, animated: false) case .reveal(let voted, let percentage, let animated): - cell.optionPercentageLabel.isHidden = false - cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" - cell.voteProgressStripView.isHidden = false - cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color - cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated) + cell.pollOptionView.optionPercentageLabel.isHidden = false + cell.pollOptionView.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" + cell.pollOptionView.voteProgressStripView.isHidden = false + cell.pollOptionView.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color + cell.pollOptionView.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated) } } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index ba58cb3f1..366c70649 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -27,6 +27,7 @@ internal enum Asset { } internal enum Circles { internal static let plusCircleFill = ImageAsset(name: "Circles/plus.circle.fill") + internal static let plusCircle = ImageAsset(name: "Circles/plus.circle") } internal enum Colors { internal enum Background { @@ -46,6 +47,7 @@ internal enum Asset { internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background") internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background") + internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background") } internal enum Button { internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar") diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json new file mode 100644 index 000000000..30eea7b43 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "plus.circle.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf new file mode 100644 index 000000000..65d55fe27 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf @@ -0,0 +1,95 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.000000 0.000000 0.000000 scn +21.999905 0.000160 m +34.035152 0.000160 44.000000 9.986404 44.000000 22.000080 c +44.000000 34.035332 34.013599 44.000000 21.978350 44.000000 c +9.964656 44.000000 0.000000 34.035332 0.000000 22.000080 c +0.000000 9.986404 9.986255 0.000160 21.999905 0.000160 c +h +21.999905 3.666824 m +11.819542 3.666824 3.688203 11.819717 3.688203 22.000080 c +3.688203 32.180443 11.797986 40.333336 21.978350 40.333336 c +32.158710 40.333336 40.311611 32.180443 40.333256 22.000080 c +40.354897 11.819717 32.180267 3.666824 21.999905 3.666824 c +h +13.782296 20.188307 m +20.166574 20.188307 l +20.166574 13.760918 l +20.166574 12.682493 20.899923 11.949142 21.956793 11.949142 c +23.035217 11.949142 23.790121 12.682493 23.790121 13.760918 c +23.790121 20.188307 l +30.217514 20.188307 l +31.295938 20.188307 32.029289 20.921658 32.029289 21.978525 c +32.029289 23.056950 31.295938 23.811855 30.217514 23.811855 c +23.790121 23.811855 l +23.790121 30.196133 l +23.790121 31.317715 23.035217 32.051018 21.956793 32.051018 c +20.899923 32.051018 20.166574 31.296114 20.166574 30.196133 c +20.166574 23.811855 l +13.782296 23.811855 l +12.660716 23.811855 11.927410 23.056950 11.927410 21.978525 c +11.927410 20.921658 12.682316 20.188307 13.782296 20.188307 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1347 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 44.000000 44.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001437 00000 n +0000001460 00000 n +0000001633 00000 n +0000001707 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1766 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json index b10e249b2..d8f32572f 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "255", - "green" : "255", - "red" : "255" + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.169", - "green" : "0.141", - "red" : "0.125" + "blue" : "0x2B", + "green" : "0x23", + "red" : "0x1F" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index edc0dce9a..d47050048 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "232", - "green" : "225", - "red" : "217" + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.169", - "green" : "0.141", - "red" : "0.125" + "blue" : "0x2B", + "green" : "0x23", + "red" : "0x1F" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json index 2388399df..d8f32572f 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json @@ -5,9 +5,27 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x43", - "green" : "0x36", - "red" : "0x32" + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2B", + "green" : "0x23", + "red" : "0x1F" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json new file mode 100644 index 000000000..d47050048 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2B", + "green" : "0x23", + "red" : "0x1F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index 80e8cf875..1537215d0 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -66,7 +66,7 @@ extension ComposeStatusContentCollectionViewCell { textEditorView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), textEditorView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), textEditorView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 20), + contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 10), textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), ]) textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusNewPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusNewPollOptionCollectionViewCell.swift new file mode 100644 index 000000000..131af21e7 --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusNewPollOptionCollectionViewCell.swift @@ -0,0 +1,120 @@ +// +// ComposeStatusNewPollOptionCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import os.log +import UIKit + +protocol ComposeStatusNewPollOptionCollectionViewCellDelegate: class { + func ComposeStatusNewPollOptionCollectionViewCellDidPressed(_ cell: ComposeStatusNewPollOptionCollectionViewCell) +} + +final class ComposeStatusNewPollOptionCollectionViewCell: UICollectionViewCell { + + let pollOptionView = PollOptionView() + + let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + override var isHighlighted: Bool { + didSet { + pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? Asset.Colors.Background.secondarySystemBackground.color : Asset.Colors.Background.systemBackground.color + pollOptionView.plusCircleImageView.tintColor = isHighlighted ? Asset.Colors.Button.normal.color.withAlphaComponent(0.5) : Asset.Colors.Button.normal.color + } + } + + weak var delegate: ComposeStatusNewPollOptionCollectionViewCellDelegate? + + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusNewPollOptionCollectionViewCell { + + private func _init() { + pollOptionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pollOptionView) + NSLayoutConstraint.activate([ + pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), + pollOptionView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + pollOptionView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + pollOptionView.checkmarkImageView.isHidden = true + pollOptionView.checkmarkBackgroundView.isHidden = true + pollOptionView.optionPercentageLabel.isHidden = true + pollOptionView.optionTextField.isHidden = true + pollOptionView.plusCircleImageView.isHidden = false + + pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemBackground.color + setupBorderColor() + + pollOptionView.addGestureRecognizer(singleTagGestureRecognizer) + singleTagGestureRecognizer.addTarget(self, action: #selector(ComposeStatusNewPollOptionCollectionViewCell.singleTagGestureRecognizerHandler(_:))) + } + + private func setupBorderColor() { + pollOptionView.roundedBackgroundView.layer.borderWidth = 1 + pollOptionView.roundedBackgroundView.layer.borderColor = Asset.Colors.Background.secondarySystemBackground.color.cgColor + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setupBorderColor() + } + +} + +extension ComposeStatusNewPollOptionCollectionViewCell { + + @objc private func singleTagGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.ComposeStatusNewPollOptionCollectionViewCellDidPressed(self) + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ComposeStatusNewPollOptionCollectionViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = ComposeStatusNewPollOptionCollectionViewCell() + return cell + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift new file mode 100644 index 000000000..3d930682b --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift @@ -0,0 +1,147 @@ +// +// ComposeStatusPollOptionCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import os.log +import UIKit +import Combine + +protocol ComposeStatusPollOptionCollectionViewCellDelegate: class { + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) +} + +final class ComposeStatusPollOptionCollectionViewCell: UICollectionViewCell { + + var disposeBag = Set() + weak var delegate: ComposeStatusPollOptionCollectionViewCellDelegate? + + let pollOptionView = PollOptionView() + + let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + private var pollOptionSubscription: AnyCancellable? + let pollOption = PassthroughSubject() + + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + disposeBag.removeAll() + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusPollOptionCollectionViewCell { + + private func _init() { + pollOptionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pollOptionView) + NSLayoutConstraint.activate([ + pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), + pollOptionView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + pollOptionView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + pollOptionView.checkmarkImageView.isHidden = true + pollOptionView.optionPercentageLabel.isHidden = true + pollOptionView.optionTextField.text = nil + + pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + pollOptionView.checkmarkBackgroundView.backgroundColor = Asset.Colors.Background.tertiarySystemBackground.color + setupBorderColor() + + pollOptionView.addGestureRecognizer(singleTagGestureRecognizer) + singleTagGestureRecognizer.addTarget(self, action: #selector(ComposeStatusPollOptionCollectionViewCell.singleTagGestureRecognizerHandler(_:))) + + pollOptionSubscription = NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: pollOptionView.optionTextField) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let self = self else { return } + guard let textField = notification.object as? UITextField else { return } + self.pollOption.send(textField.text ?? "") + } + pollOptionView.optionTextField.deleteBackwardDelegate = self + pollOptionView.optionTextField.delegate = self + } + + private func setupBorderColor() { + pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor + pollOptionView.checkmarkBackgroundView.layer.borderWidth = 1 + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setupBorderColor() + } + +} + +extension ComposeStatusPollOptionCollectionViewCell { + + @objc private func singleTagGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + pollOptionView.optionTextField.becomeFirstResponder() + } + +} + +// MARK: - DeleteBackwardResponseTextFieldDelegate +extension ComposeStatusPollOptionCollectionViewCell: DeleteBackwardResponseTextFieldDelegate { + func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) { + delegate?.composeStatusPollOptionCollectionViewCell(self, textBeforeDeleteBackward: textBeforeDelete) + } +} + +// MARK: - UITextFieldDelegate +extension ComposeStatusPollOptionCollectionViewCell: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + if textField === pollOptionView.optionTextField { + delegate?.composeStatusPollOptionCollectionViewCell(self, pollOptionTextFieldDidReturn: textField) + } + return true + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ComposeStatusPollOptionCollectionViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = ComposeStatusPollOptionCollectionViewCell() + return cell + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 760496026..3af0cb9e2 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -45,6 +45,8 @@ final class ComposeViewController: UIViewController, NeedsDependency { collectionView.register(ComposeRepliedToTootContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self)) collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) + collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) + collectionView.register(ComposeStatusNewPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusNewPollOptionCollectionViewCell.self)) collectionView.backgroundColor = Asset.Colors.Background.systemBackground.color return collectionView }() @@ -154,7 +156,9 @@ extension ComposeViewController { for: collectionView, dependency: self, textEditorViewTextAttributesDelegate: self, - composeStatusAttachmentTableViewCellDelegate: self + composeStatusAttachmentTableViewCellDelegate: self, + composeStatusPollOptionCollectionViewCellDelegate: self, + composeStatusNewPollOptionCollectionViewCellDelegate: self ) // respond scrollView overlap change @@ -205,6 +209,16 @@ extension ComposeViewController { .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: publishBarButtonItem) .store(in: &disposeBag) + + viewModel.isMediaToolbarButtonEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: composeToolbarView.mediaButton) + .store(in: &disposeBag) + + viewModel.isPollToolbarButtonEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: composeToolbarView.pollButton) + .store(in: &disposeBag) // bind custom emojis viewModel.customEmojiViewModel @@ -268,6 +282,57 @@ extension ComposeViewController { textEditorView()?.isEditing = true } + private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? { + guard case .poll = item else { return nil } + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + guard let indexPath = diffableDataSource.indexPath(for: item), + let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { + return nil + } + + return cell + } + + private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + let firstPollItem = items.first { item -> Bool in + guard case .poll = item else { return false } + return true + } + + guard let item = firstPollItem else { + return nil + } + + return pollOptionCollectionViewCell(of: item) + } + + private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + let lastPollItem = items.last { item -> Bool in + guard case .poll = item else { return false } + return true + } + + guard let item = lastPollItem else { + return nil + } + + return pollOptionCollectionViewCell(of: item) + } + + private func markFirstPollOptionCollectionViewCellBecomeFirstResponser() { + guard let cell = firstPollOptionCollectionViewCell() else { return } + cell.pollOptionView.optionTextField.becomeFirstResponder() + } + + private func markLastPollOptionCollectionViewCellBecomeFirstResponser() { + guard let cell = lastPollOptionCollectionViewCell() else { return } + cell.pollOptionView.optionTextField.becomeFirstResponder() + } + private func showDismissConfirmAlertController() { let alertController = UIAlertController( title: L10n.Common.Alerts.DiscardPostContent.title, @@ -490,7 +555,6 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { extension ComposeViewController: ComposeToolbarViewDelegate { func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, mediaSelectionType.rawValue) switch mediaSelectionType { case .photoLibrary: present(imagePicker, animated: true, completion: nil) @@ -501,20 +565,31 @@ extension ComposeViewController: ComposeToolbarViewDelegate { } } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton) { + viewModel.isPollComposing.value.toggle() + + // setup initial poll option if needs + if viewModel.isPollComposing.value, viewModel.pollAttributes.value.isEmpty { + viewModel.pollAttributes.value = [ComposeStatusItem.ComposePollAttribute(), ComposeStatusItem.ComposePollAttribute()] + } + + if viewModel.isPollComposing.value { + // Magic RunLoop + DispatchQueue.main.async { + self.markFirstPollOptionCollectionViewCellBecomeFirstResponser() + } + } else { + markTextEditorViewBecomeFirstResponser() + } } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) { } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) { } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, locationButtonDidPressed sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton) { } } @@ -622,3 +697,88 @@ extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelega } } + +// MARK: - ComposeStatusPollOptionCollectionViewCellDelegate +extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelegate { + + // handle delete backward event for poll option input + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) { + guard (text ?? "").isEmpty else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = collectionView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .poll(attribute) = item else { return } + + var pollAttributes = viewModel.pollAttributes.value + guard let index = pollAttributes.firstIndex(of: attribute) else { return } + + // mark previous (fallback to next) item of removed middle poll option become first responder + let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + if let indexOfItem = pollItems.firstIndex(of: item), index > 0 { + func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? { + guard index > 0 else { return nil } + let indexBeforeRemoved = pollItems.index(before: indexOfItem) + let itemBeforeRemoved = pollItems[indexBeforeRemoved] + return pollOptionCollectionViewCell(of: itemBeforeRemoved) + } + + func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? { + guard index < pollItems.count - 1 else { return nil } + let indexAfterRemoved = pollItems.index(after: index) + let itemAfterRemoved = pollItems[indexAfterRemoved] + return pollOptionCollectionViewCell(of: itemAfterRemoved) + } + + var cell: ComposeStatusPollOptionCollectionViewCell? = cellBeforeRemoved() + if cell == nil { + cell = cellAfterRemoved() + } + cell?.pollOptionView.optionTextField.becomeFirstResponder() + } + + guard pollAttributes.count > 2 else { + return + } + pollAttributes.remove(at: index) + + // update data source + viewModel.pollAttributes.value = pollAttributes + } + + // handle keyboard return event for poll option input + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = collectionView.indexPath(for: cell) else { return } + let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in + guard case .poll = item else { return false } + return true + } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard let index = pollItems.firstIndex(of: item) else { return } + + if index == pollItems.count - 1 { + // is the last + viewModel.createNewPollOptionIfPossible() + DispatchQueue.main.async { + self.markLastPollOptionCollectionViewCellBecomeFirstResponser() + } + } else { + // not the last + let indexAfter = pollItems.index(after: index) + let itemAfter = pollItems[indexAfter] + let cell = pollOptionCollectionViewCell(of: itemAfter) + cell?.pollOptionView.optionTextField.becomeFirstResponder() + } + } + +} + +// MARK: - ComposeStatusNewPollOptionCollectionViewCellDelegate +extension ComposeViewController: ComposeStatusNewPollOptionCollectionViewCellDelegate { + func ComposeStatusNewPollOptionCollectionViewCellDidPressed(_ cell: ComposeStatusNewPollOptionCollectionViewCell) { + viewModel.createNewPollOptionIfPossible() + DispatchQueue.main.async { + self.markLastPollOptionCollectionViewCellBecomeFirstResponser() + } + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 389d23edb..c838f3e25 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -14,7 +14,9 @@ extension ComposeViewModel { for collectionView: UICollectionView, dependency: NeedsDependency, textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, - composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, + composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, + composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusNewPollOptionCollectionViewCellDelegate ) { let diffableDataSource = ComposeStatusSection.collectionViewDiffableDataSource( for: collectionView, @@ -22,7 +24,9 @@ extension ComposeViewModel { managedObjectContext: context.managedObjectContext, composeKind: composeKind, textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, - composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate + composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate, + composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate, + composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate ) // Note: do not allow reorder due to the images display order following the upload time @@ -48,7 +52,7 @@ extension ComposeViewModel { self.diffableDataSource = diffableDataSource var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.repliedTo, .status, .attachment]) + snapshot.appendSections([.repliedTo, .status, .attachment, .poll]) switch composeKind { case .reply(let statusObjectID): snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index ea998b778..262d9bd54 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -19,6 +19,7 @@ final class ComposeViewModel { let context: AppContext let composeKind: ComposeStatusSection.ComposeKind let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() + let isPollComposing = CurrentValueSubject(false) let activeAuthentication: CurrentValueSubject let activeAuthenticationBox: CurrentValueSubject @@ -41,6 +42,8 @@ final class ComposeViewModel { let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) let isPublishBarButtonItemEnabled = CurrentValueSubject(false) + let isMediaToolbarButtonEnabled = CurrentValueSubject(true) + let isPollToolbarButtonEnabled = CurrentValueSubject(true) // custom emojis let customEmojiViewModel = CurrentValueSubject(nil) @@ -48,6 +51,9 @@ final class ComposeViewModel { // attachment let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) + // polls + let pollAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollAttribute], Never>([]) + init( context: AppContext, composeKind: ComposeStatusSection.ComposeKind @@ -137,49 +143,91 @@ final class ComposeViewModel { } .store(in: &disposeBag) - // bind snapshot and drive service upload state - attachmentServices - .receive(on: DispatchQueue.main) - .sink { [weak self] attachmentServices in - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - var snapshot = diffableDataSource.snapshot() - - snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment)) - var items: [ComposeStatusItem] = [] - for attachmentService in attachmentServices { - let item = ComposeStatusItem.attachment(attachmentService: attachmentService) - items.append(item) + // bind snapshot + Publishers.CombineLatest3( + attachmentServices.eraseToAnyPublisher(), + isPollComposing.eraseToAnyPublisher(), + pollAttributes.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices, isPollComposing, pollAttributes in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + var snapshot = diffableDataSource.snapshot() + + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment)) + var attachmentItems: [ComposeStatusItem] = [] + for attachmentService in attachmentServices { + let item = ComposeStatusItem.attachment(attachmentService: attachmentService) + attachmentItems.append(item) + } + snapshot.appendItems(attachmentItems, toSection: .attachment) + + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .poll)) + if isPollComposing { + var pollItems: [ComposeStatusItem] = [] + for pollAttribute in pollAttributes { + let item = ComposeStatusItem.poll(attribute: pollAttribute) + pollItems.append(item) } - snapshot.appendItems(items, toSection: .attachment) - - diffableDataSource.apply(snapshot) - - // make image upload in the queue - for attachmentService in attachmentServices { - // skip when prefix N task when task finish OR fail OR uploading - guard let currentState = attachmentService.uploadStateMachine.currentState else { break } - if currentState is MastodonAttachmentService.UploadState.Fail { - continue - } - if currentState is MastodonAttachmentService.UploadState.Finish { - continue - } - if currentState is MastodonAttachmentService.UploadState.Uploading { - break - } - // trigger uploading one by one - if currentState is MastodonAttachmentService.UploadState.Initial { - attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) - break - } + snapshot.appendItems(pollItems, toSection: .poll) + if pollAttributes.count < 4 { + snapshot.appendItems([ComposeStatusItem.newPoll], toSection: .poll) } } - .store(in: &disposeBag) + + diffableDataSource.apply(snapshot) + + // drive service upload state + // make image upload in the queue + for attachmentService in attachmentServices { + // skip when prefix N task when task finish OR fail OR uploading + guard let currentState = attachmentService.uploadStateMachine.currentState else { break } + if currentState is MastodonAttachmentService.UploadState.Fail { + continue + } + if currentState is MastodonAttachmentService.UploadState.Finish { + continue + } + if currentState is MastodonAttachmentService.UploadState.Uploading { + break + } + // trigger uploading one by one + if currentState is MastodonAttachmentService.UploadState.Initial { + attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) + break + } + } + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + isPollComposing.eraseToAnyPublisher(), + attachmentServices.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] isPollComposing, attachmentServices in + guard let self = self else { return } + let shouldMediaDisable = isPollComposing || attachmentServices.count >= 4 + let shouldPollDisable = attachmentServices.count > 0 + + self.isMediaToolbarButtonEnabled.value = !shouldMediaDisable + self.isPollToolbarButtonEnabled.value = !shouldPollDisable + }) + .store(in: &disposeBag) } } +extension ComposeViewModel { + func createNewPollOptionIfPossible() { + guard pollAttributes.value.count < 4 else { return } + + let attribute = ComposeStatusItem.ComposePollAttribute() + pollAttributes.value = pollAttributes.value + [attribute] + } +} + // MARK: - MastodonAttachmentServiceDelegate extension ComposeViewModel: MastodonAttachmentServiceDelegate { func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) { diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index dfbc70cb9..b109faf3e 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -5,14 +5,15 @@ // Created by MainasuK Cirno on 2021-3-12. // +import os.log import UIKit protocol ComposeToolbarViewDelegate: class { func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, locationButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton) } final class ComposeToolbarView: UIView { @@ -102,10 +103,10 @@ extension ComposeToolbarView { mediaButton.menu = createMediaContextMenu() mediaButton.showsMenuAsPrimaryAction = true - pollButton.addTarget(self, action: #selector(ComposeToolbarView.gifButtonDidPressed(_:)), for: .touchUpInside) - emojiButton.addTarget(self, action: #selector(ComposeToolbarView.atButtonDidPressed(_:)), for: .touchUpInside) - contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.topicButtonDidPressed(_:)), for: .touchUpInside) - visibilityButton.addTarget(self, action: #selector(ComposeToolbarView.locationButtonDidPressed(_:)), for: .touchUpInside) + pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside) + emojiButton.addTarget(self, action: #selector(ComposeToolbarView.emojiButtonDidPressed(_:)), for: .touchUpInside) + contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside) + visibilityButton.addTarget(self, action: #selector(ComposeToolbarView.visibilityButtonDidPressed(_:)), for: .touchUpInside) } } @@ -131,18 +132,21 @@ extension ComposeToolbarView { var children: [UIMenuElement] = [] let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function) self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .photoLibrary) } children.append(photoLibraryAction) if UIImagePickerController.isSourceTypeAvailable(.camera) { let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function) self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .camera) }) children.append(cameraAction) } let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function) self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .browse) } children.append(browseAction) @@ -155,20 +159,24 @@ extension ComposeToolbarView { extension ComposeToolbarView { - @objc private func gifButtonDidPressed(_ sender: UIButton) { - delegate?.composeToolbarView(self, gifButtonDidPressed: sender) + @objc private func pollButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeToolbarView(self, pollButtonDidPressed: sender) } - @objc private func atButtonDidPressed(_ sender: UIButton) { - delegate?.composeToolbarView(self, atButtonDidPressed: sender) + @objc private func emojiButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeToolbarView(self, emojiButtonDidPressed: sender) } - @objc private func topicButtonDidPressed(_ sender: UIButton) { - delegate?.composeToolbarView(self, topicButtonDidPressed: sender) + @objc private func contentWarningButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeToolbarView(self, contentWarningButtonDidPressed: sender) } - @objc private func locationButtonDidPressed(_ sender: UIButton) { - delegate?.composeToolbarView(self, locationButtonDidPressed: sender) + @objc private func visibilityButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeToolbarView(self, visibilityButtonDidPressed: sender) } } diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView.swift b/Mastodon/Scene/Share/View/Content/PollOptionView.swift new file mode 100644 index 000000000..4e5e5a2ae --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/PollOptionView.swift @@ -0,0 +1,200 @@ +// +// PollOptionView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import UIKit +import Combine + +final class PollOptionView: UIView { + + static let height: CGFloat = optionHeight + 2 * verticalMargin + static let optionHeight: CGFloat = 44 + static let verticalMargin: CGFloat = 5 + static let checkmarkImageSize = CGSize(width: 26, height: 26) + + private var viewStateDisposeBag = Set() + + let roundedBackgroundView = UIView() + let voteProgressStripView: StripProgressView = { + let view = StripProgressView() + view.tintColor = Asset.Colors.Background.Poll.highlight.color + return view + }() + + let checkmarkBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = .systemBackground + return view + }() + + let checkmarkImageView: UIImageView = { + let imageView = UIImageView() + let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))! + imageView.image = image.withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Button.normal.color + return imageView + }() + + let plusCircleImageView: UIImageView = { + let imageView = UIImageView() + let image = Asset.Circles.plusCircle.image + imageView.image = image.withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Button.normal.color + return imageView + }() + + let optionTextField: DeleteBackwardResponseTextField = { + let textField = DeleteBackwardResponseTextField() + textField.font = .systemFont(ofSize: 15, weight: .medium) + textField.textColor = Asset.Colors.Label.primary.color + textField.text = "Option" + textField.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .left : .right + return textField + }() + + let optionLabelMiddlePaddingView = UIView() + + let optionPercentageLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.text = "50%" + label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension PollOptionView { + private func _init() { + roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false + addSubview(roundedBackgroundView) + NSLayoutConstraint.activate([ + roundedBackgroundView.topAnchor.constraint(equalTo: topAnchor, constant: 5), + roundedBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + roundedBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor, constant: 5), + roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionView.optionHeight).priority(.defaultHigh), + ]) + + voteProgressStripView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(voteProgressStripView) + NSLayoutConstraint.activate([ + voteProgressStripView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor), + voteProgressStripView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor), + voteProgressStripView.trailingAnchor.constraint(equalTo: roundedBackgroundView.trailingAnchor), + voteProgressStripView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor), + ]) + + checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(checkmarkBackgroundView) + NSLayoutConstraint.activate([ + checkmarkBackgroundView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor, constant: 9), + checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: 9), + roundedBackgroundView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor, constant: 9), + checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollOptionView.checkmarkImageSize.width).priority(.required - 1), + checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionView.checkmarkImageSize.height).priority(.required - 1), + ]) + + checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false + checkmarkBackgroundView.addSubview(checkmarkImageView) + NSLayoutConstraint.activate([ + checkmarkImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor, constant: 5), + checkmarkImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor, constant: 5), + checkmarkBackgroundView.trailingAnchor.constraint(equalTo: checkmarkImageView.trailingAnchor, constant: 5), + checkmarkBackgroundView.bottomAnchor.constraint(equalTo: checkmarkImageView.bottomAnchor, constant: 5), + ]) + + plusCircleImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(plusCircleImageView) + NSLayoutConstraint.activate([ + plusCircleImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor), + plusCircleImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor), + plusCircleImageView.trailingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor), + plusCircleImageView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor), + ]) + + optionTextField.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionTextField) + NSLayoutConstraint.activate([ + optionTextField.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor, constant: 14), + optionTextField.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + optionTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), + ]) + + optionLabelMiddlePaddingView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionLabelMiddlePaddingView) + NSLayoutConstraint.activate([ + optionLabelMiddlePaddingView.leadingAnchor.constraint(equalTo: optionTextField.trailingAnchor), + optionLabelMiddlePaddingView.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + optionLabelMiddlePaddingView.heightAnchor.constraint(equalToConstant: 4).priority(.defaultHigh), + optionLabelMiddlePaddingView.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).priority(.defaultLow), + ]) + optionLabelMiddlePaddingView.setContentHuggingPriority(.required - 1, for: .horizontal) + optionLabelMiddlePaddingView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionPercentageLabel) + NSLayoutConstraint.activate([ + optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabelMiddlePaddingView.trailingAnchor), + roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18), + optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + ]) + optionPercentageLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + + plusCircleImageView.isHidden = true + } + + override func layoutSubviews() { + super.layoutSubviews() + updateCornerRadius() + } + +} + +extension PollOptionView { + private func updateCornerRadius() { + roundedBackgroundView.layer.masksToBounds = true + roundedBackgroundView.layer.cornerRadius = PollOptionView.optionHeight * 0.5 + roundedBackgroundView.layer.cornerCurve = .circular + + checkmarkBackgroundView.layer.masksToBounds = true + checkmarkBackgroundView.layer.cornerRadius = PollOptionView.checkmarkImageSize.width * 0.5 + checkmarkBackgroundView.layer.cornerCurve = .circular + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PollOptionView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + PollOptionView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 2fd3a023d..b067896a1 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -10,54 +10,8 @@ import Combine final class PollOptionTableViewCell: UITableViewCell { - static let height: CGFloat = optionHeight + 2 * verticalMargin - static let optionHeight: CGFloat = 44 - static let verticalMargin: CGFloat = 5 - static let checkmarkImageSize = CGSize(width: 26, height: 26) - - private var viewStateDisposeBag = Set() + let pollOptionView = PollOptionView() var attribute: PollItem.Attribute? - - let roundedBackgroundView = UIView() - let voteProgressStripView: StripProgressView = { - let view = StripProgressView() - view.tintColor = Asset.Colors.Background.Poll.highlight.color - return view - }() - - let checkmarkBackgroundView: UIView = { - let view = UIView() - view.backgroundColor = .systemBackground - return view - }() - - let checkmarkImageView: UIView = { - let imageView = UIImageView() - let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))! - imageView.image = image.withRenderingMode(.alwaysTemplate) - imageView.tintColor = Asset.Colors.Button.normal.color - return imageView - }() - - let optionLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 15, weight: .medium) - label.textColor = Asset.Colors.Label.primary.color - label.text = "Option" - label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .left : .right - return label - }() - - let optionLabelMiddlePaddingView = UIView() - - let optionPercentageLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 13, weight: .regular) - label.textColor = Asset.Colors.Label.primary.color - label.text = "50%" - label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left - return label - }() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -76,7 +30,7 @@ final class PollOptionTableViewCell: UITableViewCell { switch voteState { case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color - self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color + pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color case .reveal: break } @@ -89,7 +43,7 @@ final class PollOptionTableViewCell: UITableViewCell { switch voteState { case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color - self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color + pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color case .reveal: break } @@ -102,125 +56,55 @@ extension PollOptionTableViewCell { private func _init() { selectionStyle = .none backgroundColor = .clear - roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + pollOptionView.optionTextField.isUserInteractionEnabled = false - roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(roundedBackgroundView) + pollOptionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pollOptionView) NSLayoutConstraint.activate([ - roundedBackgroundView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5), - roundedBackgroundView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - roundedBackgroundView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor, constant: 5), - roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.optionHeight).priority(.defaultHigh), + pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), + pollOptionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + pollOptionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) - - voteProgressStripView.translatesAutoresizingMaskIntoConstraints = false - roundedBackgroundView.addSubview(voteProgressStripView) - NSLayoutConstraint.activate([ - voteProgressStripView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor), - voteProgressStripView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor), - voteProgressStripView.trailingAnchor.constraint(equalTo: roundedBackgroundView.trailingAnchor), - voteProgressStripView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor), - ]) - - checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false - roundedBackgroundView.addSubview(checkmarkBackgroundView) - NSLayoutConstraint.activate([ - checkmarkBackgroundView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor, constant: 9), - checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: 9), - roundedBackgroundView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor, constant: 9), - checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.width).priority(.defaultHigh), - checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.height).priority(.defaultHigh), - ]) - - checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false - checkmarkBackgroundView.addSubview(checkmarkImageView) - NSLayoutConstraint.activate([ - checkmarkImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor, constant: 5), - checkmarkImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor, constant: 5), - checkmarkBackgroundView.trailingAnchor.constraint(equalTo: checkmarkImageView.trailingAnchor, constant: 5), - checkmarkBackgroundView.bottomAnchor.constraint(equalTo: checkmarkImageView.bottomAnchor, constant: 5), - ]) - - optionLabel.translatesAutoresizingMaskIntoConstraints = false - roundedBackgroundView.addSubview(optionLabel) - NSLayoutConstraint.activate([ - optionLabel.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor, constant: 14), - optionLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), - ]) - - optionLabelMiddlePaddingView.translatesAutoresizingMaskIntoConstraints = false - roundedBackgroundView.addSubview(optionLabelMiddlePaddingView) - NSLayoutConstraint.activate([ - optionLabelMiddlePaddingView.leadingAnchor.constraint(equalTo: optionLabel.trailingAnchor), - optionLabelMiddlePaddingView.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), - optionLabelMiddlePaddingView.heightAnchor.constraint(equalToConstant: 4).priority(.defaultHigh), - optionLabelMiddlePaddingView.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).priority(.defaultLow), - ]) - optionLabelMiddlePaddingView.setContentHuggingPriority(.defaultLow, for: .horizontal) - - optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false - roundedBackgroundView.addSubview(optionPercentageLabel) - NSLayoutConstraint.activate([ - optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabelMiddlePaddingView.trailingAnchor), - roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18), - optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), - ]) - optionPercentageLabel.setContentHuggingPriority(.required - 1, for: .horizontal) - optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) } - - override func layoutSubviews() { - super.layoutSubviews() - - updateCornerRadius() - updateTextAppearance() - } - - private func updateCornerRadius() { - roundedBackgroundView.layer.masksToBounds = true - roundedBackgroundView.layer.cornerRadius = PollOptionTableViewCell.optionHeight * 0.5 - roundedBackgroundView.layer.cornerCurve = .circular - - checkmarkBackgroundView.layer.masksToBounds = true - checkmarkBackgroundView.layer.cornerRadius = PollOptionTableViewCell.checkmarkImageSize.width * 0.5 - checkmarkBackgroundView.layer.cornerCurve = .circular - } - + func updateTextAppearance() { guard let voteState = attribute?.voteState else { - optionLabel.textColor = Asset.Colors.Label.primary.color - optionLabel.layer.removeShadow() + pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color + pollOptionView.optionTextField.layer.removeShadow() return } switch voteState { case .hidden: - optionLabel.textColor = Asset.Colors.Label.primary.color - optionLabel.layer.removeShadow() + pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color + pollOptionView.optionTextField.layer.removeShadow() case .reveal(_, let percentage, _): - if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.minX { - optionLabel.textColor = .white - optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + if CGFloat(percentage) * pollOptionView.voteProgressStripView.frame.width > pollOptionView.optionLabelMiddlePaddingView.frame.minX { + pollOptionView.optionTextField.textColor = .white + pollOptionView.optionTextField.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) } else { - optionLabel.textColor = Asset.Colors.Label.primary.color - optionLabel.layer.removeShadow() + pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color + pollOptionView.optionTextField.layer.removeShadow() } - if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.maxX { - optionPercentageLabel.textColor = .white - optionPercentageLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + if CGFloat(percentage) * pollOptionView.voteProgressStripView.frame.width > pollOptionView.optionLabelMiddlePaddingView.frame.maxX { + pollOptionView.optionPercentageLabel.textColor = .white + pollOptionView.optionPercentageLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) } else { - optionPercentageLabel.textColor = Asset.Colors.Label.primary.color - optionPercentageLabel.layer.removeShadow() + pollOptionView.optionPercentageLabel.textColor = Asset.Colors.Label.primary.color + pollOptionView.optionPercentageLabel.layer.removeShadow() } } - + } + + override func layoutSubviews() { + super.layoutSubviews() + updateTextAppearance() } } - #if canImport(SwiftUI) && DEBUG import SwiftUI diff --git a/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift b/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift new file mode 100644 index 000000000..21c80dcf8 --- /dev/null +++ b/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift @@ -0,0 +1,24 @@ +// +// DeleteBackwardResponseTextField.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import UIKit + +protocol DeleteBackwardResponseTextFieldDelegate: class { + func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) +} + +final class DeleteBackwardResponseTextField: UITextField { + + weak var deleteBackwardDelegate: DeleteBackwardResponseTextFieldDelegate? + + override func deleteBackward() { + let text = self.text + super.deleteBackward() + deleteBackwardDelegate?.deleteBackwardResponseTextField(self, textBeforeDelete: text) + } + +}