Merge pull request #137 from tootsuite/feature/auto-complete

Add auto complete supports for post compose scene
This commit is contained in:
CMK 2021-05-18 15:21:22 +08:00 committed by GitHub
commit 948481909d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1519 additions and 108 deletions

View File

@ -340,6 +340,10 @@
"private": "Followers only",
"direct": "Only people I mention"
},
"auto_complete": {
"single_people_talking": "%ld people talking",
"multiple_people_talking": "%ld people talking"
},
"accessibility": {
"append_attachment": "Append attachment",
"append_poll": "Append poll",

View File

@ -189,6 +189,8 @@
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; };
DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; };
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; };
DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */; };
DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ED026538E3C00BEE9D8 /* Trie.swift */; };
DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; };
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; };
DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; };
@ -206,8 +208,6 @@
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; };
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; };
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; };
DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; };
DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; };
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; };
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; };
@ -311,6 +311,11 @@
DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F7C26358ED4008423CD /* SettingsSection.swift */; };
DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F8326358EEC008423CD /* SettingsItem.swift */; };
DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; };
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */; };
DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB6F5E31264E7410009108F4 /* TwitterTextEditor */; };
DB6F5E33264E7410009108F4 /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB6F5E31264E7410009108F4 /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */; };
DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */; };
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; };
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; };
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; };
@ -414,6 +419,12 @@
DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */; };
DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; };
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */; };
DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DC126524D2900E5B703 /* AutoCompleteTableViewCell.swift */; };
DBBF1DC5265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DC4265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift */; };
DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DC6265251D400E5B703 /* AutoCompleteViewModel+State.swift */; };
DBBF1DC92652538500E5B703 /* AutoCompleteSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */; };
DBBF1DCB2652539E00E5B703 /* AutoCompleteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */; };
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; };
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; };
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */; };
@ -527,7 +538,7 @@
files = (
DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */,
DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */,
DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */,
DB6F5E33264E7410009108F4 /* TwitterTextEditor in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
@ -731,6 +742,8 @@
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = "<group>"; };
DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = "<group>"; };
DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCollectionView.swift; sourceTree = "<group>"; };
DB040ED026538E3C00BEE9D8 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = "<group>"; };
DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = "<group>"; };
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = "<group>"; };
DB0F814D264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
@ -853,6 +866,9 @@
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = "<group>"; };
DB6D9F8326358EEC008423CD /* SettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItem.swift; sourceTree = "<group>"; };
DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = "<group>"; };
DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewController.swift; sourceTree = "<group>"; };
DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = "<group>"; };
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = "<group>"; };
DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = "<group>"; };
DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = "<group>"; };
@ -956,6 +972,12 @@
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardMeterView.swift; sourceTree = "<group>"; };
DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewModel.swift; sourceTree = "<group>"; };
DBBF1DC126524D2900E5B703 /* AutoCompleteTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTableViewCell.swift; sourceTree = "<group>"; };
DBBF1DC4265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AutoCompleteViewModel+Diffable.swift"; sourceTree = "<group>"; };
DBBF1DC6265251D400E5B703 /* AutoCompleteViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AutoCompleteViewModel+State.swift"; sourceTree = "<group>"; };
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteSection.swift; sourceTree = "<group>"; };
DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteItem.swift; sourceTree = "<group>"; };
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = "<group>"; };
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = "<group>"; };
DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
@ -993,7 +1015,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */,
DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */,
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */,
DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */,
2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */,
@ -1362,6 +1384,7 @@
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
DB6D9F7C26358ED4008423CD /* SettingsSection.swift */,
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
);
path = Section;
sourceTree = "<group>";
@ -1422,6 +1445,7 @@
DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */,
DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */,
DB6D9F8326358EEC008423CD /* SettingsItem.swift */,
DBBF1DCA2652539E00E5B703 /* AutoCompleteItem.swift */,
);
path = Item;
sourceTree = "<group>";
@ -1754,6 +1778,7 @@
DB49A61325FF2C5600B98345 /* EmojiService.swift */,
DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */,
DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */,
DB040ED026538E3C00BEE9D8 /* Trie.swift */,
);
path = EmojiService;
sourceTree = "<group>";
@ -1771,6 +1796,7 @@
DB55D32225FB4D320002F825 /* View */ = {
isa = PBXGroup;
children = (
DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */,
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */,
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */,
DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */,
@ -1874,6 +1900,19 @@
path = MastodonSDK;
sourceTree = "<group>";
};
DB6F5E36264E78EA009108F4 /* AutoComplete */ = {
isa = PBXGroup;
children = (
DBBF1DC02652402000E5B703 /* View */,
DBBF1DC326524D3100E5B703 /* Cell */,
DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */,
DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */,
DBBF1DC4265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift */,
DBBF1DC6265251D400E5B703 /* AutoCompleteViewModel+State.swift */,
);
path = AutoComplete;
sourceTree = "<group>";
};
DB72602125E36A2500235243 /* ServerRules */ = {
isa = PBXGroup;
children = (
@ -1894,6 +1933,7 @@
DB789A1025F9F29B0071ACA0 /* Compose */ = {
isa = PBXGroup;
children = (
DB6F5E36264E78EA009108F4 /* AutoComplete */,
DB55D32225FB4D320002F825 /* View */,
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */,
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */,
@ -2199,6 +2239,7 @@
isa = PBXGroup;
children = (
2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */,
DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */,
DB35FC2E26130172006193C9 /* MastodonField.swift */,
DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */,
);
@ -2302,6 +2343,22 @@
path = View;
sourceTree = "<group>";
};
DBBF1DC02652402000E5B703 /* View */ = {
isa = PBXGroup;
children = (
DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */,
);
path = View;
sourceTree = "<group>";
};
DBBF1DC326524D3100E5B703 /* Cell */ = {
isa = PBXGroup;
children = (
DBBF1DC126524D2900E5B703 /* AutoCompleteTableViewCell.swift */,
);
path = Cell;
sourceTree = "<group>";
};
DBCBED2226132E1D00B49291 /* FetchedResultsController */ = {
isa = PBXGroup;
children = (
@ -2400,7 +2457,7 @@
2D939AC725EE14620076FA61 /* CropViewController */,
DB9A487D2603456B008B817C /* UITextView+Placeholder */,
DBB525072611EAC0002F1F29 /* Tabman */,
DB35B0B22643D821006AC73B /* TwitterTextEditor */,
DB6F5E31264E7410009108F4 /* TwitterTextEditor */,
);
productName = Mastodon;
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
@ -2589,7 +2646,7 @@
DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */,
DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */,
DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */,
DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */,
DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */,
);
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
projectDirPath = "";
@ -2846,6 +2903,7 @@
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */,
DBBF1DC7265251D400E5B703 /* AutoCompleteViewModel+State.swift in Sources */,
DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */,
2D7631B325C159F700929FB9 /* Item.swift in Sources */,
5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */,
@ -2998,6 +3056,7 @@
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+Provider.swift in Sources */,
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */,
DBBF1DC92652538500E5B703 /* AutoCompleteSection.swift in Sources */,
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
@ -3019,6 +3078,7 @@
DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */,
2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */,
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */,
DBBF1DCB2652539E00E5B703 /* AutoCompleteItem.swift in Sources */,
2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */,
DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */,
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */,
@ -3035,6 +3095,7 @@
2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */,
DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */,
@ -3045,6 +3106,7 @@
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
2D61254D262547C200299647 /* APIService+Notification.swift in Sources */,
DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */,
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */,
5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */,
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */,
@ -3057,6 +3119,7 @@
DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */,
5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */,
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
DBBF1DC5265251C300E5B703 /* AutoCompleteViewModel+Diffable.swift in Sources */,
DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */,
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */,
DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */,
@ -3068,6 +3131,7 @@
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */,
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
@ -3135,6 +3199,7 @@
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */,
DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */,
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */,
0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */,
@ -3142,6 +3207,7 @@
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */,
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */,
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
@ -3160,6 +3226,7 @@
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */,
DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */,
DB6D9F6F2635807F008423CD /* Setting.swift in Sources */,
DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */,
DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */,
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
@ -3168,6 +3235,7 @@
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */,
DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */,
DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */,
DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */,
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */,
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
@ -3938,14 +4006,6 @@
minimumVersion = 0.1.1;
};
};
DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/twitter/TwitterTextEditor";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.1.0;
};
};
DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Alamofire/AlamofireImage.git";
@ -3970,6 +4030,14 @@
minimumVersion = 4.2.2;
};
};
DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/MainasuK/TwitterTextEditor.git";
requirement = {
branch = "feature/expose-layout";
kind = branch;
};
};
DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder";
@ -4031,11 +4099,6 @@
package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */;
productName = CommonOSLog;
};
DB35B0B22643D821006AC73B /* TwitterTextEditor */ = {
isa = XCSwiftPackageProductDependency;
package = DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */;
productName = TwitterTextEditor;
};
DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = {
isa = XCSwiftPackageProductDependency;
package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */;
@ -4056,6 +4119,11 @@
package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */;
productName = AlamofireImage;
};
DB6F5E31264E7410009108F4 /* TwitterTextEditor */ = {
isa = XCSwiftPackageProductDependency;
package = DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */;
productName = TwitterTextEditor;
};
DB9A487D2603456B008B817C /* UITextView+Placeholder */ = {
isa = XCSwiftPackageProductDependency;
package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */;

View File

@ -69,7 +69,7 @@
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
"state": {
"branch": null,
"revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e",
"revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8",
"version": "6.2.1"
}
},
@ -138,11 +138,11 @@
},
{
"package": "TwitterTextEditor",
"repositoryURL": "https://github.com/twitter/TwitterTextEditor",
"repositoryURL": "https://github.com/MainasuK/TwitterTextEditor.git",
"state": {
"branch": null,
"revision": "dfe0edc3bcb6703ee2fd0e627f95e726b63e732a",
"version": "1.1.0"
"branch": "feature/expose-layout",
"revision": "c208329b23dcb3c8c7192de34776440d625a26a4",
"version": null
}
},
{

View File

@ -0,0 +1,55 @@
//
// AutoCompleteItem.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-17.
//
import Foundation
import MastodonSDK
enum AutoCompleteItem {
case hashtag(tag: Mastodon.Entity.Tag)
case hashtagV1(tag: String)
case account(account: Mastodon.Entity.Account)
case emoji(emoji: Mastodon.Entity.Emoji)
case bottomLoader
}
extension AutoCompleteItem: Equatable {
static func == (lhs: AutoCompleteItem, rhs: AutoCompleteItem) -> Bool {
switch (lhs, rhs) {
case (.hashtag(let tagLeft), hashtag(let tagRight)):
return tagLeft.name == tagRight.name
case (.hashtagV1(let tagLeft), hashtagV1(let tagRight)):
return tagLeft == tagRight
case (.account(let accountLeft), account(let accountRight)):
return accountLeft.id == accountRight.id
case (.emoji(let emojiLeft), .emoji(let emojiRight)):
return emojiLeft.shortcode == emojiRight.shortcode
case (.bottomLoader, .bottomLoader):
return true
default:
return false
}
}
}
extension AutoCompleteItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .hashtag(let tag):
hasher.combine(tag.name)
hasher.combine(tag.url)
case .hashtagV1(let tag):
hasher.combine(tag)
case .account(let account):
hasher.combine(account.id)
case .emoji(let emoji):
hasher.combine(emoji.shortcode)
hasher.combine(emoji.url)
case .bottomLoader:
hasher.combine(String(describing: AutoCompleteItem.bottomLoader.self))
}
}
}

View File

@ -0,0 +1,90 @@
//
// AutoCompleteSection.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-17.
//
import UIKit
import MastodonSDK
enum AutoCompleteSection: Equatable, Hashable {
case main
}
extension AutoCompleteSection {
static func tableViewDiffableDataSource(
for tableView: UITableView
) -> UITableViewDiffableDataSource<AutoCompleteSection, AutoCompleteItem> {
UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in
switch item {
case .hashtag(let hashtag):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AutoCompleteTableViewCell.self), for: indexPath) as! AutoCompleteTableViewCell
configureHashtag(cell: cell, hashtag: hashtag)
return cell
case .hashtagV1(let hashtagName):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AutoCompleteTableViewCell.self), for: indexPath) as! AutoCompleteTableViewCell
configureHashtag(cell: cell, hashtagName: hashtagName)
return cell
case .account(let account):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AutoCompleteTableViewCell.self), for: indexPath) as! AutoCompleteTableViewCell
configureAccount(cell: cell, account: account)
return cell
case .emoji(let emoji):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AutoCompleteTableViewCell.self), for: indexPath) as! AutoCompleteTableViewCell
configureEmoji(cell: cell, emoji: emoji)
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
return cell
}
}
}
}
extension AutoCompleteSection {
private static func configureHashtag(cell: AutoCompleteTableViewCell, hashtag: Mastodon.Entity.Tag) {
cell.titleLabel.text = "#" + hashtag.name
cell.subtitleLabel.text = {
let count = (hashtag.history ?? [])
.sorted(by: { $0.day > $1.day })
.prefix(2)
.compactMap { Int($0.accounts) }
.reduce(0, +)
if count > 1 {
return L10n.Scene.Compose.AutoComplete.multiplePeopleTalking(count)
} else {
return L10n.Scene.Compose.AutoComplete.singlePeopleTalking(count)
}
}()
cell.avatarImageView.isHidden = true
}
private static func configureHashtag(cell: AutoCompleteTableViewCell, hashtagName: String) {
cell.titleLabel.text = "#" + hashtagName
cell.subtitleLabel.text = " "
cell.avatarImageView.isHidden = true
}
private static func configureAccount(cell: AutoCompleteTableViewCell, account: Mastodon.Entity.Account) {
cell.titleLabel.text = {
guard !account.displayName.isEmpty else { return account.username }
return account.displayName
}()
cell.subtitleLabel.text = "@" + account.acct
cell.avatarImageView.isHidden = false
cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: account.avatar)))
}
private static func configureEmoji(cell: AutoCompleteTableViewCell, emoji: Mastodon.Entity.Emoji) {
cell.titleLabel.text = ":" + emoji.shortcode + ":"
cell.subtitleLabel.text = " "
cell.avatarImageView.isHidden = false
cell.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: URL(string: emoji.url)))
}
}

View File

@ -37,6 +37,7 @@ extension ComposeStatusSection {
repliedToCellFrameSubscriber: CurrentValueSubject<CGRect, Never>,
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
textEditorViewChangeObserver: TextEditorViewChangeObserver,
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
@ -45,6 +46,7 @@ extension ComposeStatusSection {
UICollectionViewDiffableDataSource(collectionView: collectionView) { [
weak customEmojiPickerInputViewModel,
weak textEditorViewTextAttributesDelegate,
weak textEditorViewChangeObserver,
weak composeStatusAttachmentTableViewCellDelegate,
weak composeStatusPollOptionCollectionViewCellDelegate,
weak composeStatusNewPollOptionCollectionViewCellDelegate,
@ -92,6 +94,7 @@ extension ComposeStatusSection {
}
ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute)
cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate
cell.textEditorViewChangeObserver = textEditorViewChangeObserver // relay
cell.composeContent
.removeDuplicates()
.receive(on: DispatchQueue.main)
@ -259,7 +262,7 @@ extension ComposeStatusSection {
}
protocol CustomEmojiReplacableTextInput: AnyObject {
protocol CustomEmojiReplaceableTextInput: AnyObject {
var inputView: UIView? { get set }
func reloadInputViews()
@ -270,14 +273,14 @@ protocol CustomEmojiReplacableTextInput: AnyObject {
}
class CustomEmojiReplacableTextInputReference {
weak var value: CustomEmojiReplacableTextInput?
weak var value: CustomEmojiReplaceableTextInput?
init(value: CustomEmojiReplacableTextInput? = nil) {
init(value: CustomEmojiReplaceableTextInput? = nil) {
self.value = value
}
}
extension TextEditorView: CustomEmojiReplacableTextInput {
extension TextEditorView: CustomEmojiReplaceableTextInput {
func insertText(_ text: String) {
try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil)
}
@ -287,14 +290,14 @@ extension TextEditorView: CustomEmojiReplacableTextInput {
}
}
extension UITextField: CustomEmojiReplacableTextInput { }
extension UITextView: CustomEmojiReplacableTextInput { }
extension UITextField: CustomEmojiReplaceableTextInput { }
extension UITextView: CustomEmojiReplaceableTextInput { }
extension ComposeStatusSection {
static func configureCustomEmojiPicker(
viewModel: CustomEmojiPickerInputViewModel?,
customEmojiReplacableTextInput: CustomEmojiReplacableTextInput,
customEmojiReplacableTextInput: CustomEmojiReplaceableTextInput,
disposeBag: inout Set<AnyCancellable>
) {
guard let viewModel = viewModel else { return }

View File

@ -152,41 +152,4 @@ extension ActiveLabel {
return elements
}
// public override func accessibilityElementCount() -> Int {
// return 1 + activeEntities.count
// }
//
// public override func accessibilityElement(at index: Int) -> Any? {
// if index == 0 {
// let element = ActiveLabelAccessibilityElement(accessibilityContainer: self)
// element.accessibilityTraits = .staticText
// element.accessibilityLabel = accessibilityLabel
// element.accessibilityFrame = superview!.convert(frame, to: nil)
// element.index = index
// return element
// }
//
// let index = index - 1
// guard index < activeEntities.count else { return nil }
// let eneity = activeEntities[index]
// guard let element = eneity.accessibilityElement(in: self) else { return nil }
//
// var glyphRange = NSRange()
// layoutManager.characterRange(forGlyphRange: eneity.range, actualGlyphRange: &glyphRange)
// let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
// element.accessibilityFrame = self.convert(rect, to: nil)
// element.accessibilityContainer = self
//
// return element
// }
//
// public override func index(ofAccessibilityElement element: Any) -> Int {
// guard let element = element as? ActiveLabelAccessibilityElement,
// let index = element.index else {
// return NSNotFound
// }
//
// return index
// }
}

View File

@ -376,6 +376,16 @@ internal enum L10n {
/// video
internal static let video = L10n.tr("Localizable", "Scene.Compose.Attachment.Video")
}
internal enum AutoComplete {
/// %ld people talking
internal static func multiplePeopleTalking(_ p1: Int) -> String {
return L10n.tr("Localizable", "Scene.Compose.AutoComplete.MultiplePeopleTalking", p1)
}
/// %ld people talking
internal static func singlePeopleTalking(_ p1: Int) -> String {
return L10n.tr("Localizable", "Scene.Compose.AutoComplete.SinglePeopleTalking", p1)
}
}
internal enum ContentWarning {
/// Write an accurate warning here...
internal static let placeholder = L10n.tr("Localizable", "Scene.Compose.ContentWarning.Placeholder")

View File

@ -0,0 +1,25 @@
//
// MastodonRegex.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-14.
//
import Foundation
enum MastodonRegex {
/// mention, hashtag.
/// @...
/// #...
static let highlightPattern = "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))"
/// emoji
/// :shortcode:
/// accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect
/// precondition :\B with following space
static let emojiPattern = "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))"
/// mention, hashtag, emoji
/// @
/// #
/// :
static let autoCompletePattern = "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))|(^\\B:|\\s:)([a-zA-Z0-9_]+)"
}

View File

@ -125,6 +125,8 @@ uploaded to Mastodon.";
"Scene.Compose.Attachment.DescriptionVideo" = "Describe whats happening for low vision people...";
"Scene.Compose.Attachment.Photo" = "photo";
"Scene.Compose.Attachment.Video" = "video";
"Scene.Compose.AutoComplete.MultiplePeopleTalking" = "%ld people talking";
"Scene.Compose.AutoComplete.SinglePeopleTalking" = "%ld people talking";
"Scene.Compose.ComposeAction" = "Publish";
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind";
"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here...";

View File

@ -125,6 +125,8 @@ uploaded to Mastodon.";
"Scene.Compose.Attachment.DescriptionVideo" = "Describe whats happening for low vision people...";
"Scene.Compose.Attachment.Photo" = "photo";
"Scene.Compose.Attachment.Video" = "video";
"Scene.Compose.AutoComplete.MultiplePeopleTalking" = "%ld people talking";
"Scene.Compose.AutoComplete.SinglePeopleTalking" = "%ld people talking";
"Scene.Compose.ComposeAction" = "Publish";
"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind";
"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here...";

View File

@ -0,0 +1,116 @@
//
// AutoCompleteViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-14.
//
import os.log
import UIKit
import Combine
protocol AutoCompleteViewControllerDelegate: AnyObject {
func autoCompleteViewController(_ viewController: AutoCompleteViewController, didSelectItem item: AutoCompleteItem)
}
final class AutoCompleteViewController: UIViewController {
static let chevronViewHeight: CGFloat = 24
var viewModel: AutoCompleteViewModel!
var disposeBag = Set<AnyCancellable>()
weak var delegate: AutoCompleteViewControllerDelegate?
let chevronView = AutoCompleteTopChevronView()
let containerBackgroundView: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.Background.systemBackground.color
return view
}()
let tableView: UITableView = {
let tableView = ControlContainableTableView()
tableView.register(AutoCompleteTableViewCell.self, forCellReuseIdentifier: String(describing: AutoCompleteTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
tableView.contentInset.top = AutoCompleteViewController.chevronViewHeight
tableView.verticalScrollIndicatorInsets.top = AutoCompleteViewController.chevronViewHeight
return tableView
}()
}
extension AutoCompleteViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
chevronView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(chevronView)
NSLayoutConstraint.activate([
chevronView.topAnchor.constraint(equalTo: view.topAnchor),
chevronView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
chevronView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
chevronView.heightAnchor.constraint(equalToConstant: AutoCompleteViewController.chevronViewHeight)
])
containerBackgroundView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(containerBackgroundView)
NSLayoutConstraint.activate([
containerBackgroundView.topAnchor.constraint(equalTo: chevronView.topAnchor),
containerBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
containerBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
containerBackgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
view.bringSubviewToFront(chevronView)
containerBackgroundView.preservesSuperviewLayoutMargins = true
containerBackgroundView.isUserInteractionEnabled = true
tableView.translatesAutoresizingMaskIntoConstraints = false
containerBackgroundView.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: containerBackgroundView.topAnchor),
tableView.leadingAnchor.constraint(equalTo: containerBackgroundView.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: containerBackgroundView.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: containerBackgroundView.bottomAnchor),
])
tableView.delegate = self
viewModel.setupDiffableDataSource(for: tableView)
// bind to layout chevron
viewModel.symbolBoundingRect
.receive(on: DispatchQueue.main)
.sink { [weak self] symbolBoundingRect in
guard let self = self else { return }
self.chevronView.chevronMinX = symbolBoundingRect.midX - 0.5 * AutoCompleteTopChevronView.chevronSize.width
self.chevronView.setNeedsLayout()
self.containerBackgroundView.layer.mask = self.chevronView.invertMask(in: self.view.bounds)
}
.store(in: &disposeBag)
}
}
// MARK: - UITableViewDelegate
extension AutoCompleteViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
tableView.deselectRow(at: indexPath, animated: true)
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
delegate?.autoCompleteViewController(self, didSelectItem: item)
}
func tableView(_ tableView: UITableView, didHighlightRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
}
}

View File

@ -0,0 +1,22 @@
//
// AutoCompleteViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-17.
//
import UIKit
extension AutoCompleteViewModel {
func setupDiffableDataSource(
for tableView: UITableView
) {
diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(for: tableView)
var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
}
}

View File

@ -0,0 +1,206 @@
//
// AutoCompleteViewModel+State.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-17.
//
import os.log
import Foundation
import GameplayKit
import MastodonSDK
extension AutoCompleteViewModel {
class State: GKState {
weak var viewModel: AutoCompleteViewModel?
init(viewModel: AutoCompleteViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
}
}
}
extension AutoCompleteViewModel.State {
class Initial: AutoCompleteViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }
switch stateClass {
case is Loading.Type:
return !viewModel.inputText.value.isEmpty
default:
return false
}
}
}
class Loading: AutoCompleteViewModel.State {
var previoursSearchText = ""
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }
switch stateClass {
case is Loading.Type:
return previoursSearchText != viewModel.inputText.value
case is Fail.Type:
return true
case is Idle.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
let searchText = viewModel.inputText.value
let searchType = AutoCompleteViewModel.SearchType(inputText: searchText) ?? .default
if searchText != previoursSearchText {
reset(searchText: searchText)
}
switch searchType {
case .emoji:
Loading.fetchLocalEmoji(
searchText: searchText,
viewModel: viewModel,
stateMachine: stateMachine
)
default:
Loading.queryRemoteEnitity(
searchText: searchText,
viewModel: viewModel,
stateMachine: stateMachine
)
}
}
private static func fetchLocalEmoji(
searchText: String,
viewModel: AutoCompleteViewModel,
stateMachine: GKStateMachine
) {
guard let customEmojiViewModel = viewModel.customEmojiViewModel.value else {
stateMachine.enter(Fail.self)
return
}
guard let emojiTrie = customEmojiViewModel.emojiTrie.value else {
stateMachine.enter(Fail.self)
return
}
let searchPattern = ArraySlice(String(searchText.dropFirst()))
let passthroughs = emojiTrie.passthrough(searchPattern)
let matchingEmojis = passthroughs
.map { $0.values } // [Set<Emoji>]
.map { set in set.compactMap { $0 as? Mastodon.Entity.Emoji } } // [[Emoji]]
.flatMap { $0 } // [Emoji]
let items: [AutoCompleteItem] = matchingEmojis.map { emoji in
AutoCompleteItem.emoji(emoji: emoji)
}
stateMachine.enter(Idle.self)
viewModel.autoCompleteItems.value = items
}
private static func queryRemoteEnitity(
searchText: String,
viewModel: AutoCompleteViewModel,
stateMachine: GKStateMachine
) {
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
let domain = activeMastodonAuthenticationBox.domain
let searchText = viewModel.inputText.value
let searchType = AutoCompleteViewModel.SearchType(inputText: searchText) ?? .default
let q = String(searchText.dropFirst())
let query = Mastodon.API.V2.Search.Query(
q: q,
type: searchType.mastodonSearchType,
maxID: nil,
offset: nil,
following: nil
)
viewModel.context.apiService.search(
domain: domain,
query: query,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto-complete fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
stateMachine.enter(Fail.self)
case .finished:
break
}
} receiveValue: { response in
guard viewModel.inputText.value == searchText else { return } // discard if not matching
var items: [AutoCompleteItem] = []
items.append(contentsOf: response.value.accounts.map { AutoCompleteItem.account(account: $0) })
items.append(contentsOf: response.value.hashtags.map { AutoCompleteItem.hashtag(tag: $0) })
stateMachine.enter(Idle.self)
viewModel.autoCompleteItems.value = items
}
.store(in: &viewModel.disposeBag)
}
private func reset(searchText: String) {
let previoursSearchType = AutoCompleteViewModel.SearchType(inputText: previoursSearchText)
previoursSearchText = searchText
let currentSearchType = AutoCompleteViewModel.SearchType(inputText: searchText)
// reset when search type change
if previoursSearchType != currentSearchType {
viewModel?.autoCompleteItems.value = []
}
}
}
class Idle: AutoCompleteViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
}
class Fail: AutoCompleteViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let _ = viewModel, let stateMachine = stateMachine else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
stateMachine.enter(Loading.self)
}
}
}
}

View File

@ -0,0 +1,107 @@
//
// AutoCompleteViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-17.
//
import UIKit
import Combine
import GameplayKit
import MastodonSDK
final class AutoCompleteViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let inputText = CurrentValueSubject<String, Never>("") // contains "@" or "#" prefix
let symbolBoundingRect = CurrentValueSubject<CGRect, Never>(.zero)
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
// output
var autoCompleteItems = CurrentValueSubject<[AutoCompleteItem], Never>([])
var diffableDataSource: UITableViewDiffableDataSource<AutoCompleteSection, AutoCompleteItem>!
private(set) lazy var stateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
State.Initial(viewModel: self),
State.Loading(viewModel: self),
State.Fail(viewModel: self),
State.Idle(viewModel: self),
])
stateMachine.enter(State.Initial.self)
return stateMachine
}()
init(context: AppContext) {
self.context = context
autoCompleteItems
.receive(on: DispatchQueue.main)
.sink { [weak self] items in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items, toSection: .main)
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Loading, is State.Fail:
if items.isEmpty {
snapshot.appendItems([.bottomLoader], toSection: .main)
}
case is State.Idle:
// TODO: handle no results
break
default:
break
}
}
diffableDataSource.defaultRowAnimation = .fade
diffableDataSource.apply(snapshot)
}
.store(in: &disposeBag)
inputText
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] inputText in
guard let self = self else { return }
self.stateMachine.enter(State.Loading.self)
}
.store(in: &disposeBag)
}
}
extension AutoCompleteViewModel {
enum SearchType {
case accounts
case hashtags
case emoji
case `default`
public var mastodonSearchType: Mastodon.API.V2.Search.SearchType? {
switch self {
case .accounts: return .accounts
case .hashtags: return .hashtags
case .emoji: return nil
case .default: return .default
}
}
init?(inputText: String) {
let prefix = inputText.first ?? Character("_")
switch prefix {
case "@": self = .accounts
case "#": self = .hashtags
case ":": self = .emoji
default: return nil
}
}
}
}

View File

@ -0,0 +1,156 @@
//
// AutoCompleteTableViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-17.
//
import UIKit
final class AutoCompleteTableViewCell: UITableViewCell {
static let avatarImageSize = CGSize(width: 42, height: 42)
static let avatarImageCornerRadius: CGFloat = 4
static let avatarToLabelSpacing: CGFloat = 12
let containerStackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 12
return stackView
}()
let contentStackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.distribution = .fillEqually
return stackView
}()
let avatarImageView = UIImageView()
let titleLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)
label.textColor = Asset.Colors.Label.highlight.color
label.text = "Title"
return label
}()
let subtitleLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20)
label.textColor = Asset.Colors.Label.secondary.color
label.text = "subtitle"
return label
}()
override func prepareForReuse() {
super.prepareForReuse()
avatarImageView.af.cancelImageRequest()
avatarImageView.kf.cancelDownloadTask()
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
super.setHighlighted(highlighted, animated: animated)
// workaround for hitTest trigger highlighted issue
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if self.isHighlighted {
self.setHighlighted(false, animated: true)
}
}
}
}
extension AutoCompleteTableViewCell {
private func _init() {
backgroundColor = .clear
let topPaddingView = UIView()
let bottomPaddingView = UIView()
topPaddingView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(topPaddingView)
NSLayoutConstraint.activate([
topPaddingView.topAnchor.constraint(equalTo: contentView.topAnchor),
topPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
topPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
topPaddingView.heightAnchor.constraint(equalToConstant: 12).priority(.defaultHigh),
])
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
])
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(avatarImageView)
NSLayoutConstraint.activate([
avatarImageView.heightAnchor.constraint(equalToConstant: AutoCompleteTableViewCell.avatarImageSize.height).priority(.required - 1),
avatarImageView.widthAnchor.constraint(equalToConstant: AutoCompleteTableViewCell.avatarImageSize.width).priority(.required - 1),
])
containerStackView.addArrangedSubview(contentStackView)
contentStackView.addArrangedSubview(titleLabel)
contentStackView.addArrangedSubview(subtitleLabel)
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(bottomPaddingView)
NSLayoutConstraint.activate([
bottomPaddingView.topAnchor.constraint(equalTo: contentStackView.bottomAnchor),
bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 1.0),
])
}
}
// MARK: - AvatarConfigurableView
extension AutoCompleteTableViewCell: AvatarConfigurableView {
static var configurableAvatarImageSize: CGSize { avatarImageSize }
static var configurableAvatarImageCornerRadius: CGFloat { avatarImageCornerRadius }
var configurableAvatarImageView: UIImageView? { avatarImageView }
var configurableAvatarButton: UIButton? { nil }
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct AutoCompleteTableViewCell_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview() {
let cell = AutoCompleteTableViewCell()
return cell
}
.previewLayout(.fixed(width: 375, height: 66))
UIViewPreview() {
let cell = AutoCompleteTableViewCell()
return cell
}
.preferredColorScheme(.dark)
.previewLayout(.fixed(width: 375, height: 66))
}
}
}
#endif

View File

@ -0,0 +1,178 @@
//
// AutoCompleteTopChevronView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-14.
//
import UIKit
final class AutoCompleteTopChevronView: UIView {
static let chevronSize = CGSize(width: 20, height: 12)
private let shadowView = UIView()
private let shadowLayer = CAShapeLayer()
private let maskLayer = CAShapeLayer()
var chevronMinX: CGFloat = 0
var topViewBackgroundColor = Asset.Scene.Compose.background.color {
didSet { setNeedsLayout() }
}
var bottomViewBackgroundColor = Asset.Colors.Background.systemBackground.color {
didSet { setNeedsLayout() }
}
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension AutoCompleteTopChevronView {
var standardizedChevronMinX: CGFloat {
min(max(chevronMinX, 0), bounds.width - AutoCompleteTopChevronView.chevronSize.width)
}
var edgePoints: [CGPoint] {
[
CGPoint(x: standardizedChevronMinX, y: bounds.maxY),
CGPoint(x: standardizedChevronMinX + 0.5 * AutoCompleteTopChevronView.chevronSize.width, y: bounds.maxY - AutoCompleteTopChevronView.chevronSize.height),
CGPoint(x: standardizedChevronMinX + AutoCompleteTopChevronView.chevronSize.width, y: bounds.maxY),
CGPoint(x: bounds.width, y: bounds.maxY),
]
}
}
extension AutoCompleteTopChevronView {
private func _init() {
clipsToBounds = false
backgroundColor = .clear
isUserInteractionEnabled = false
shadowView.translatesAutoresizingMaskIntoConstraints = false
addSubview(shadowView)
NSLayoutConstraint.activate([
shadowView.topAnchor.constraint(equalTo: topAnchor),
shadowView.leadingAnchor.constraint(equalTo: leadingAnchor),
shadowView.trailingAnchor.constraint(equalTo: trailingAnchor),
shadowView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
shadowLayer.fillColor = topViewBackgroundColor.cgColor
shadowView.layer.addSublayer(shadowLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
// 1. setup shadow with chevron
shadowLayer.path = {
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: bounds.maxY))
// bottom edge
for point in edgePoints {
path.addLine(to: point)
}
// up egde
path.addLine(to: CGPoint(x: bounds.maxX, y: 0))
path.addLine(to: CGPoint(x: 0, y: 0))
path.close()
return path.cgPath
}()
shadowLayer.fillColor = topViewBackgroundColor.cgColor
// 2. setup mask to clip shadow
maskLayer.path = {
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: bounds.maxY))
// up edge
for point in edgePoints {
path.addLine(to: CGPoint(x: point.x, y: point.y - 2)) // move up 2pt
}
// bottom egde
path.addLine(to: CGPoint(x: bounds.maxX, y: 2 * bounds.maxY))
path.addLine(to: CGPoint(x: 0, y: 2 * bounds.maxY))
path.close()
return path.cgPath
}()
maskLayer.fillColor = UIColor.red.cgColor
shadowView.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.25))
shadowView.layer.mask = maskLayer
layer.mask = maskLayer
}
}
extension AutoCompleteTopChevronView {
func invertMask(in rect: CGRect) -> CAShapeLayer {
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: bounds.maxY))
// top edge
for point in edgePoints {
path.addLine(to: point)
}
// bottom edge
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: 0, y: rect.maxY))
path.close()
let mask = CAShapeLayer()
mask.fillColor = UIColor.red.cgColor
mask.path = path.cgPath
return mask
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct AutoCompleteTopChevronView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 375) {
let view = AutoCompleteTopChevronView()
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 375),
view.heightAnchor.constraint(equalToConstant: 100),
])
view.chevronMinX = 10
return view
}
.background(Color(Asset.Scene.Compose.background.color))
.padding(20)
.previewLayout(.fixed(width: 375 + 40, height: 100 + 40))
UIViewPreview(width: 375) {
let view = AutoCompleteTopChevronView()
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 375),
view.heightAnchor.constraint(equalToConstant: 100),
])
view.chevronMinX = 10
return view
}
.background(Color(Asset.Scene.Compose.background.color))
.preferredColorScheme(.dark)
.padding(20)
.previewLayout(.fixed(width: 375 + 40, height: 100 + 40))
}
}
}
#endif

View File

@ -18,6 +18,7 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell {
let statusContentWarningEditorView = StatusContentWarningEditorView()
let textEditorViewContainerView = UIView()
let textEditorView: TextEditorView = {
let textEditorView = TextEditorView()
textEditorView.font = .preferredFont(forTextStyle: .body)
@ -27,6 +28,9 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell {
textEditorView.keyboardType = .twitter
return textEditorView
}()
// input
weak var textEditorViewChangeObserver: TextEditorViewChangeObserver?
// output
let composeContent = PassthroughSubject<String, Never>()
@ -75,13 +79,23 @@ extension ComposeStatusContentCollectionViewCell {
statusView.setContentHuggingPriority(.defaultHigh, for: .vertical)
statusView.setContentCompressionResistancePriority(.required - 1, for: .vertical)
textEditorView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(textEditorView)
textEditorViewContainerView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(textEditorViewContainerView)
NSLayoutConstraint.activate([
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: 10),
textEditorViewContainerView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10),
textEditorViewContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
textEditorViewContainerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor, constant: 10),
])
textEditorViewContainerView.preservesSuperviewLayoutMargins = true
textEditorView.translatesAutoresizingMaskIntoConstraints = false
textEditorViewContainerView.addSubview(textEditorView)
NSLayoutConstraint.activate([
textEditorView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor),
textEditorView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.leadingAnchor),
textEditorView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.trailingAnchor),
textEditorView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor),
textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
])
textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical)
@ -98,6 +112,10 @@ extension ComposeStatusContentCollectionViewCell {
// MARK: - TextEditorViewChangeObserver
extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver {
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
defer {
textEditorViewChangeObserver?.textEditorView(textEditorView, didChangeWithChangeResult: changeResult)
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text)
guard changeResult.isTextChanged else { return }
composeContent.send(textEditorView.text)

View File

@ -15,6 +15,8 @@ import TwitterTextEditor
final class ComposeViewController: UIViewController, NeedsDependency {
static let minAutoCompleteVisibleHeight: CGFloat = 100
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -40,9 +42,9 @@ final class ComposeViewController: UIViewController, NeedsDependency {
return barButtonItem
}()
let collectionView: UICollectionView = {
let collectionView: ComposeCollectionView = {
let collectionViewLayout = ComposeViewController.createLayout()
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self))
collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self))
collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self))
@ -93,6 +95,16 @@ final class ComposeViewController: UIViewController, NeedsDependency {
return documentPickerController
}()
private(set) lazy var autoCompleteViewController: AutoCompleteViewController = {
let viewController = AutoCompleteViewController()
viewController.viewModel = AutoCompleteViewModel(context: context)
viewController.delegate = self
viewModel.customEmojiViewModel
.assign(to: \.value, on: viewController.viewModel.customEmojiViewModel)
.store(in: &disposeBag)
return viewController
}()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
@ -166,6 +178,7 @@ extension ComposeViewController {
dependency: self,
customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: self,
textEditorViewChangeObserver: self,
composeStatusAttachmentTableViewCellDelegate: self,
composeStatusPollOptionCollectionViewCellDelegate: self,
composeStatusNewPollOptionCollectionViewCellDelegate: self,
@ -181,26 +194,27 @@ extension ComposeViewController {
dependency: self
)
// respond scrollView overlap change
//view.layoutIfNeeded()
// update layout when keyboard show/dismiss
Publishers.CombineLatest4(
KeyboardResponderService.shared.isShow.eraseToAnyPublisher(),
KeyboardResponderService.shared.state.eraseToAnyPublisher(),
KeyboardResponderService.shared.endFrame.eraseToAnyPublisher(),
viewModel.isCustomEmojiComposing.eraseToAnyPublisher()
let keyboardEventPublishers = Publishers.CombineLatest3(
KeyboardResponderService.shared.isShow,
KeyboardResponderService.shared.state,
KeyboardResponderService.shared.endFrame
)
.sink(receiveValue: { [weak self] isShow, state, endFrame, isCustomEmojiComposing in
Publishers.CombineLatest3(
keyboardEventPublishers,
viewModel.isCustomEmojiComposing,
viewModel.autoCompleteInfo
)
.sink(receiveValue: { [weak self] keyboardEvents, isCustomEmojiComposing, autoCompleteInfo in
guard let self = self else { return }
let (isShow, state, endFrame) = keyboardEvents
let extraMargin: CGFloat = {
if self.view.safeAreaInsets.bottom == .zero {
// needs extra margin for zero inset device to workaround UIKit issue
return self.composeToolbarView.frame.height
} else {
// default some magic 16 extra margin
return 16
var margin = self.composeToolbarView.frame.height
if autoCompleteInfo != nil {
margin += ComposeViewController.minAutoCompleteVisibleHeight
}
return margin
}()
// update keyboard background color
@ -208,6 +222,17 @@ extension ComposeViewController {
guard isShow, state == .dock else {
self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
if let superView = self.autoCompleteViewController.tableView.superview {
let autoCompleteTableViewBottomInset: CGFloat = {
let tableViewFrameInWindow = superView.convert(self.autoCompleteViewController.tableView.frame, to: nil)
let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - self.view.frame.maxY
return max(0, padding)
}()
self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset
self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
}
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
if self.view.window != nil {
@ -219,29 +244,60 @@ extension ComposeViewController {
}
// isShow AND dock state
self.systemKeyboardHeight = endFrame.height
// adjust inset for auto-complete
let autoCompleteTableViewBottomInset: CGFloat = {
let tableViewFrameInWindow = self.autoCompleteViewController.tableView.superview!.convert(self.autoCompleteViewController.tableView.frame, to: nil)
let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - endFrame.minY
return max(0, padding)
}()
self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset
self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
// adjust inset for collectionView
let contentFrame = self.view.convert(self.collectionView.frame, to: nil)
let padding = contentFrame.maxY - endFrame.minY
let padding = contentFrame.maxY + extraMargin - endFrame.minY
guard padding > 0 else {
self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
self.view.layoutIfNeeded()
}
self.updateKeyboardBackground(isKeyboardDisplay: false)
return
}
self.collectionView.contentInset.bottom = padding + extraMargin
self.collectionView.verticalScrollIndicatorInsets.bottom = padding + extraMargin
self.collectionView.contentInset.bottom = padding
self.collectionView.verticalScrollIndicatorInsets.bottom = padding
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = padding
self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height
self.view.layoutIfNeeded()
}
self.updateKeyboardBackground(isKeyboardDisplay: isShow)
})
.store(in: &disposeBag)
// bind auto-complete
viewModel.autoCompleteInfo
.receive(on: DispatchQueue.main)
.sink { [weak self] info in
guard let self = self else { return }
guard let textEditorView = self.textEditorView() else { return }
if self.autoCompleteViewController.view.superview == nil {
self.autoCompleteViewController.view.frame = self.view.bounds
// add to container view. seealso: `viewDidLayoutSubviews()`
textEditorView.superview!.addSubview(self.autoCompleteViewController.view)
self.addChild(self.autoCompleteViewController)
self.autoCompleteViewController.didMove(toParent: self)
self.autoCompleteViewController.view.isHidden = true
self.collectionView.autoCompleteViewController = self.autoCompleteViewController
}
self.autoCompleteViewController.view.isHidden = info == nil
guard let info = info else { return }
let symbolBoundingRectInContainer = textEditorView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView)
self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY
self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer
self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText)
}
.store(in: &disposeBag)
// bind publish bar button state
viewModel.isPublishBarButtonItemEnabled
@ -382,6 +438,18 @@ extension ComposeViewController {
viewModel.traitCollectionDidChangePublisher.send()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// pin autoCompleteViewController frame to window
if let containerView = autoCompleteViewController.view.superview {
let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: nil)
if viewFrameInWindow.origin.x != 0 {
autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x
}
}
}
}
extension ComposeViewController {
@ -600,10 +668,8 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string)
let stringRange = NSRange(location: 0, length: string.length)
let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))")
// accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect
// precondition :\B with following space
let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))")
let highlightMatches = string.matches(pattern: MastodonRegex.highlightPattern)
let emojiMatches = string.matches(pattern: MastodonRegex.emojiPattern)
// only accept http/https scheme
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
@ -729,6 +795,115 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
}
// MARK: - TextEditorViewChangeObserver
extension ComposeViewController: TextEditorViewChangeObserver {
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
guard var autoCompeletion = ComposeViewController.scanAutoCompleteInfo(textEditorView: textEditorView) else {
viewModel.autoCompleteInfo.value = nil
return
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompeletion.toHighlightEndString), String(autoCompeletion.toCursorString))
// get layout text bounding rect
var glyphRange = NSRange()
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompeletion.toCursorRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
let textContainer = textEditorView.layoutManager.textContainers[0]
let textBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes.value
guard textBoundingRect.size != .zero else {
viewModel.autoCompleteRetryLayoutTimes.value += 1
// avoid infinite loop
guard retryLayoutTimes < 3 else { return }
// needs retry calculate layout when the rect position changing
DispatchQueue.main.async {
self.textEditorView(textEditorView, didChangeWithChangeResult: changeResult)
}
return
}
viewModel.autoCompleteRetryLayoutTimes.value = 0
// get symbol bounding rect
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompeletion.symbolRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
let symbolBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
// set bounding rect and trigger layout
autoCompeletion.textBoundingRect = textBoundingRect
autoCompeletion.symbolBoundingRect = symbolBoundingRect
viewModel.autoCompleteInfo.value = autoCompeletion
}
struct AutoCompleteInfo {
// model
let inputText: Substring
// range
let symbolRange: Range<String.Index>
let symbolString: Substring
let toCursorRange: Range<String.Index>
let toCursorString: Substring
let toHighlightEndRange: Range<String.Index>
let toHighlightEndString: Substring
// geometry
var textBoundingRect: CGRect = .zero
var symbolBoundingRect: CGRect = .zero
}
private static func scanAutoCompleteInfo(textEditorView: TextEditorView) -> AutoCompleteInfo? {
let text = textEditorView.text
let cursorLocation = textEditorView.selectedRange.location
let cursorIndex = text.index(text.startIndex, offsetBy: cursorLocation)
guard cursorLocation > 0, !text.isEmpty else { return nil }
let _highlighStartIndex: String.Index? = {
var index = text.index(text.startIndex, offsetBy: cursorLocation - 1)
while index > text.startIndex {
let char = text[index]
if char == "@" || char == "#" || char == ":" {
return index
}
index = text.index(before: index)
}
assert(index == text.startIndex)
let char = text[index]
if char == "@" || char == "#" || char == ":" {
return index
} else {
return nil
}
}()
guard let highlighStartIndex = _highlighStartIndex else { return nil }
let scanRange = NSRange(highlighStartIndex..<text.endIndex, in: text)
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
let matchRange = match.range(at: 0)
let matchStartIndex = text.index(text.startIndex, offsetBy: matchRange.location)
let matchEndIndex = text.index(matchStartIndex, offsetBy: matchRange.length)
guard matchStartIndex == highlighStartIndex, matchEndIndex >= cursorIndex else { return nil }
let symbolRange = highlighStartIndex..<text.index(after: highlighStartIndex)
let symbolString = text[symbolRange]
let toCursorRange = highlighStartIndex..<cursorIndex
let toCursorString = text[toCursorRange]
let toHighlightEndRange = matchStartIndex..<matchEndIndex
let toHighlightEndString = text[toHighlightEndRange]
let inputText = toHighlightEndString
let autoCompleteInfo = AutoCompleteInfo(
inputText: inputText,
symbolRange: symbolRange,
symbolString: symbolString,
toCursorRange: toCursorRange,
toCursorString: toCursorString,
toHighlightEndRange: toHighlightEndRange,
toHighlightEndString: toHighlightEndString
)
return autoCompleteInfo
}
}
// MARK: - ComposeToolbarViewDelegate
extension ComposeViewController: ComposeToolbarViewDelegate {
@ -814,7 +989,7 @@ extension ComposeViewController {
}
}
// MARK: - UITableViewDelegate
// MARK: - UICollectionViewDelegate
extension ComposeViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
@ -1047,3 +1222,38 @@ extension ComposeViewController: ComposeStatusPollExpiresOptionCollectionViewCel
viewModel.pollExpiresOptionAttribute.expiresOption.value = expiresOption
}
}
// MARK: - AutoCompleteViewControllerDelegate
extension ComposeViewController: AutoCompleteViewControllerDelegate {
func autoCompleteViewController(_ viewController: AutoCompleteViewController, didSelectItem item: AutoCompleteItem) {
guard let info = viewModel.autoCompleteInfo.value else { return }
let _replacedText: String? = {
var text: String
switch item {
case .hashtag(let hashtag):
text = "#" + hashtag.name
case .hashtagV1(let hashtagName):
text = "#" + hashtagName
case .account(let account):
text = "@" + account.acct
case .emoji(let emoji):
text = ":" + emoji.shortcode + ":"
case .bottomLoader:
return nil
}
text.append(" ")
return text
}()
guard let replacedText = _replacedText else { return }
guard let textEditorView = textEditorView() else { return }
let text = textEditorView.text
do {
try textEditorView.updateByReplacing(range: NSRange(info.toHighlightEndRange, in: text), with: replacedText)
viewModel.autoCompleteInfo.value = nil
} catch {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete fail %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
}
}
}

View File

@ -17,6 +17,7 @@ extension ComposeViewModel {
dependency: NeedsDependency,
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
textEditorViewChangeObserver: TextEditorViewChangeObserver,
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
@ -30,6 +31,7 @@ extension ComposeViewModel {
repliedToCellFrameSubscriber: repliedToCellFrame,
customEmojiPickerInputViewModel: customEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate,
textEditorViewChangeObserver: textEditorViewChangeObserver,
composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate,
composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate,

View File

@ -31,6 +31,8 @@ final class ComposeViewModel {
let activeAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make intial event emit
let repliedToCellFrame = CurrentValueSubject<CGRect, Never>(.zero)
let autoCompleteRetryLayoutTimes = CurrentValueSubject<Int, Never>(0)
let autoCompleteInfo = CurrentValueSubject<ComposeViewController.AutoCompleteInfo?, Never>(nil)
// output
var diffableDataSource: UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>!
@ -59,8 +61,8 @@ final class ComposeViewModel {
let characterCount = CurrentValueSubject<Int, Never>(0)
let collectionViewState = CurrentValueSubject<CollectionViewState, Never>(.fold)
// for hashtag: #<hashag>' '
// for mention: @<mention>' '
// for hashtag: "#<hashag> "
// for mention: "@<mention> "
private(set) var preInsertedContent: String?
// custom emojis

View File

@ -0,0 +1,28 @@
//
// ComposeCollectionView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-17.
//
import UIKit
final class ComposeCollectionView: UICollectionView {
weak var autoCompleteViewController: AutoCompleteViewController?
// adjust hitTest for auto-complete
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let autoCompleteViewController = autoCompleteViewController else {
return super.hitTest(point, with: event)
}
let thePoint = convert(point, to: autoCompleteViewController.view)
if let hitView = autoCompleteViewController.view.hitTest(thePoint, with: event) {
return hitView
} else {
return super.hitTest(point, with: event)
}
}
}

View File

@ -30,7 +30,7 @@ extension CustomEmojiPickerInputViewModel {
})
}
func append(customEmojiReplacableTextInput textInput: CustomEmojiReplacableTextInput) {
func append(customEmojiReplacableTextInput textInput: CustomEmojiReplaceableTextInput) {
removeEmptyReferences()
let isContains = customEmojiReplacableTextInputReferences.contains(where: { element in

View File

@ -33,6 +33,7 @@ extension EmojiService {
}()
let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([])
let emojiDict = CurrentValueSubject<[String: [Mastodon.Entity.Emoji]], Never>([:])
let emojiTrie = CurrentValueSubject<Trie<Character>?, Never>(nil)
private var learnedEmoji: Set<String> = Set()
@ -44,6 +45,19 @@ extension EmojiService {
.map { Dictionary(grouping: $0, by: { $0.shortcode }) }
.assign(to: \.value, on: emojiDict)
.store(in: &disposeBag)
emojis
.map { emojis -> Trie<Character>? in
guard !emojis.isEmpty else { return nil }
var trie: Trie<Character> = Trie()
for emoji in emojis {
let key = emoji.shortcode.lowercased()
trie.inserted(Array(key).slice, value: emoji)
}
return trie
}
.assign(to: \.value, on: emojiTrie)
.store(in: &disposeBag)
}
func emoji(shortcode: String) -> Mastodon.Entity.Emoji? {

View File

@ -0,0 +1,115 @@
//
// AutoCompleteViewModel+Trie.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-18.
//
import Foundation
struct Trie<Element: Hashable> {
let isElement: Bool
let valueSet: NSMutableSet
var children: [Element: Trie<Element>]
}
extension Trie {
init() {
isElement = false
valueSet = NSMutableSet()
children = [:]
}
init(_ key: ArraySlice<Element>, value: Any) {
if let (head, tail) = key.decomposed {
let children = [head: Trie(tail, value: value)]
self = Trie(isElement: false, valueSet: NSMutableSet(), children: children)
} else {
self = Trie(isElement: true, valueSet: NSMutableSet(object: value), children: [:])
}
}
}
extension Trie {
var elements: [[Element]] {
var result: [[Element]] = isElement ? [[]] : []
for (key, value) in children {
result += value.elements.map { [key] + $0 }
}
return result
}
}
//extension Array {
// var slice: ArraySlice<Element> {
// return ArraySlice(self)
// }
//}
extension ArraySlice {
var decomposed: (Element, ArraySlice<Element>)? {
return isEmpty ? nil : (self[startIndex], self.dropFirst())
}
}
extension Trie {
func lookup(key: ArraySlice<Element>) -> Bool {
guard let (head, tail) = key.decomposed else { return isElement }
guard let subtrie = children[head] else { return false }
return subtrie.lookup(key: tail)
}
func lookup(key: ArraySlice<Element>) -> Trie<Element>? {
guard let (head, tail) = key.decomposed else { return self }
guard let remainder = children[head] else { return nil }
return remainder.lookup(key: tail)
}
}
extension Trie {
func complete(key: ArraySlice<Element>) -> [[Element]] {
return lookup(key: key)?.elements ?? []
}
}
extension Trie {
mutating func inserted(_ key: ArraySlice<Element>, value: Any) {
guard let (head, tail) = key.decomposed else {
self.valueSet.add(value)
return
}
if var nextTrie = children[head] {
nextTrie.inserted(tail, value: value)
children[head] = nextTrie
} else {
children[head] = Trie(tail, value: value)
}
}
}
extension Trie {
func passthrough(_ key: ArraySlice<Element>) -> [Trie<Element>] {
guard let (head, tail) = key.decomposed else {
return [self]
}
let passthroughed = children[head]?.passthrough(tail) ?? []
if isElement {
return passthroughed + [self]
} else {
return passthroughed
}
}
var values: NSSet {
let valueSet = NSMutableSet(set: self.valueSet)
for (key, value) in children {
valueSet.addObjects(from: Array(value.values))
}
return valueSet
}
}

View File

@ -73,3 +73,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
#if DEBUG
class TestWindow: UIWindow {
override func sendEvent(_ event: UIEvent) {
event.allTouches?.forEach({ (touch) in
let location = touch.location(in: self)
let view = hitTest(location, with: event)
print(view.debugDescription)
})
super.sendEvent(event)
}
}
#endif