- 轨迹事件点气泡显示事件图标名称

- emoji面板显示卡顿优化
- 弹窗UI(查找位置、注册)
This commit is contained in:
linshujie 2026-07-02 18:33:52 +08:00
parent 6de0cfd68a
commit a64e1bcc43
102 changed files with 2754 additions and 127 deletions

View File

@ -194,6 +194,7 @@
30A87A6D2FEF5BA10095E7C6 /* SearchLocationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A6C2FEF5BA10095E7C6 /* SearchLocationVC.swift */; };
30A87A6F2FEF7BE40095E7C6 /* SearchLocationResultVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A6E2FEF7BE40095E7C6 /* SearchLocationResultVC.swift */; };
30A87A712FEF7BED0095E7C6 /* SearchLocationResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A702FEF7BED0095E7C6 /* SearchLocationResultView.swift */; };
30ACD54C2FF6336000174861 /* PopupWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30ACD54B2FF6336000174861 /* PopupWindow.swift */; };
30B74B3A2FF2115A00F6744D /* GroupScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B392FF2115A00F6744D /* GroupScheduleView.swift */; };
30B74B3C2FF2117900F6744D /* GroupScheduleVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B3B2FF2117900F6744D /* GroupScheduleVC.swift */; };
30B74B412FF2437E00F6744D /* GroupMemberListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B402FF2437E00F6744D /* GroupMemberListVC.swift */; };
@ -488,6 +489,7 @@
30A87A6C2FEF5BA10095E7C6 /* SearchLocationVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocationVC.swift; sourceTree = "<group>"; };
30A87A6E2FEF7BE40095E7C6 /* SearchLocationResultVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocationResultVC.swift; sourceTree = "<group>"; };
30A87A702FEF7BED0095E7C6 /* SearchLocationResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocationResultView.swift; sourceTree = "<group>"; };
30ACD54B2FF6336000174861 /* PopupWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupWindow.swift; sourceTree = "<group>"; };
30B74B392FF2115A00F6744D /* GroupScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupScheduleView.swift; sourceTree = "<group>"; };
30B74B3B2FF2117900F6744D /* GroupScheduleVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupScheduleVC.swift; sourceTree = "<group>"; };
30B74B402FF2437E00F6744D /* GroupMemberListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberListVC.swift; sourceTree = "<group>"; };
@ -1062,6 +1064,7 @@
305A762C2FCA8C7000227D26 /* Launch */,
3062E8B32FCE6BA400CEF511 /* Scan */,
30DC18592FD11E7A0041DCD1 /* Web */,
30ACD54A2FF6332200174861 /* PopupWindow */,
30EFF3AD2FD7FF1400EB35D4 /* TextInput */,
);
path = Section;
@ -1338,6 +1341,14 @@
path = SearchLocation;
sourceTree = "<group>";
};
30ACD54A2FF6332200174861 /* PopupWindow */ = {
isa = PBXGroup;
children = (
30ACD54B2FF6336000174861 /* PopupWindow.swift */,
);
path = PopupWindow;
sourceTree = "<group>";
};
30B74B382FF2105C00F6744D /* GroupSchedule */ = {
isa = PBXGroup;
children = (
@ -1898,6 +1909,7 @@
305A76C72FCA8C7000227D26 /* UserConfigModel.swift in Sources */,
30B74B472FF3608600F6744D /* ScheduleRecordModel.swift in Sources */,
305A76C82FCA8C7000227D26 /* UserConfigResponse.swift in Sources */,
30ACD54C2FF6336000174861 /* PopupWindow.swift in Sources */,
305A76C92FCA8C7000227D26 /* ApiManager.swift in Sources */,
305A76CA2FCA8C7000227D26 /* AppSettings.swift in Sources */,
305A76CB2FCA8C7000227D26 /* Authorize.swift in Sources */,

View File

@ -3,22 +3,4 @@
uuid = "AE547311-A826-480F-B1ED-BD6B815729FA"
type = "0"
version = "2.0">
<Breakpoints>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "AA447600-D860-4876-A728-F18CC2D566FC"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "QuickLocation/AppDelegate.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "70"
endingLineNumber = "70"
landmarkName = "application(_:open:options:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Slice 3@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Slice 3@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group_327@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group_327@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group_2159@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group_2159@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group_326@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group_326@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "pause@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "pause@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group_2174@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group_2174@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "slide@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "slide@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "start@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "start@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group_1545@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group_1545@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group 2127@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group 2127@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group 2188@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group 2188@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group_2323@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group_2323@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group_2331@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group_2331@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "register_text@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "register_text@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "search_bg@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "search_bg@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "组 3 2@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "组 3 2@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "image 111@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "image 111@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group_2322@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group_2322@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "image 107@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "image 107@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "image 108@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "image 108@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "vip1_unlock@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "vip1_unlock@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "vip1_logo@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "vip1_logo@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group 2086@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group 2086@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group 2087@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group 2087@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group 2088@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group 2088@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group 2209@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group 2209@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@ -73,6 +73,8 @@ enum Route: String {
case groupSchedule = "groupSchedule"
///
case groupMemberList = "groupMemberList"
///
case itineraryTrace = "itineraryTrace"
}
extension Route: RouterTarget {
@ -338,6 +340,12 @@ extension AppRouter: AppRouterProtocol {
AppRouter.register(Route.groupMemberList) { url, parameters in
GroupMemberListVC(groupKey: parameters["groupKey"].safeString)
}
// MARK: -
AppRouter.register(Route.itineraryTrace) { url, parameters in
ItineraryTraceVC(scheduleJson: parameters["model"].safeDictionary as! [String : Any],
memberInfo: parameters["memberInfo"].safeDictionary as! [String : Any])
}
}
}

View File

@ -122,6 +122,9 @@ final class GroupChatVC: BaseViewController {
// MARK: - Bindings
private func bindViewModel() {
rootView.tableView.rx.setDelegate(self)
.disposed(by: disposeBag)
viewModel.output.messages
.skip(1)
.map { [ChatSectionModel(model: "", items: $0)] }
@ -158,10 +161,7 @@ final class GroupChatVC: BaseViewController {
private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
if let path = Bundle.main.path(forResource: name, ofType: "json") {
cell.configure(path: path)
cell.playAnimation()
}
cell.configure(name: name)
return cell
}
}()
@ -300,6 +300,9 @@ final class GroupChatVC: BaseViewController {
// MARK: -
private func showEmojiPanel() {
let show = self.rootView.emojiPanelView.isHidden
if !show {
self.rootView.stopVisibleEmojiAnimations()
}
self.rootView.emojiPanelView.isHidden = !show
let offset: CGFloat = show ? 220 : 0
UIView.animate(withDuration: 0.25) {
@ -313,6 +316,10 @@ final class GroupChatVC: BaseViewController {
if show {
self.rootView.textField.resignFirstResponder()
DispatchQueue.main.async {
self.rootView.playVisibleEmojiAnimations()
self.rootView.preloadAdjacentEmojiPages()
}
}
}
@ -495,6 +502,19 @@ final class GroupChatVC: BaseViewController {
}()
}
// MARK: - UITableViewDelegate
extension GroupChatVC: UITableViewDelegate {
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
(cell as? EmojiSendMsgCell)?.playAnimation()
(cell as? EmojiReceivedMsgCell)?.playAnimation()
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
(cell as? EmojiSendMsgCell)?.stopAnimation()
(cell as? EmojiReceivedMsgCell)?.stopAnimation()
}
}
// MARK: - MessageListenerProxy
private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener {
private let handler: (OIMMessageInfo) -> Void

View File

@ -44,6 +44,7 @@ class GroupChatView: UIView {
var bottomBarBottomConstraint: NSLayoutConstraint?
///
var onVoiceRecordState: ((VoiceRecordState) -> Void)?
private var emojiPlaybackGeneration = 0
private func setupRx() {
@ -56,6 +57,7 @@ class GroupChatView: UIView {
|| textField.isFirstResponder
guard needsReset else { return }
stopVisibleEmojiAnimations()
emojiPanelView.isHidden = true
voiceRecordView.isHidden = true
if !excludeTextField { textField.resignFirstResponder() }
@ -64,6 +66,44 @@ class GroupChatView: UIView {
self.voiceRecordView.layoutChain.bottom(-252)
}
}
func playVisibleEmojiAnimations() {
emojiPlaybackGeneration += 1
let generation = emojiPlaybackGeneration
let indexPaths = emojiCollectionView.indexPathsForVisibleItems.sorted { $0.item < $1.item }
for (offset, indexPath) in indexPaths.enumerated() {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(offset) * 0.02) { [weak self] in
guard let self = self,
self.emojiPlaybackGeneration == generation,
!self.emojiCollectionView.isDragging,
!self.emojiCollectionView.isDecelerating,
let cell = self.emojiCollectionView.cellForItem(at: indexPath) as? EmojiPanelCell
else { return }
cell.playAnimation()
}
}
}
func stopVisibleEmojiAnimations() {
emojiPlaybackGeneration += 1
emojiCollectionView.visibleCells.forEach { cell in
(cell as? EmojiPanelCell)?.stopAnimation()
}
}
func preloadAdjacentEmojiPages() {
let items = UIView.emojiFileNames
guard emojiCollectionView.bounds.width > 0, !items.isEmpty else { return }
let currentPage = Int(round(emojiCollectionView.contentOffset.x / emojiCollectionView.bounds.width))
for page in [currentPage - 1, currentPage + 1] where page >= 0 {
let start = page * Self.emojiPerPage
guard start < items.count else { continue }
let end = min(start + Self.emojiPerPage, items.count)
items[start..<end].forEach { name in
EmojiPanelCell.loadAnimation(for: name) { _ in }
}
}
}
// MARK: - Setup
private func setupUI() {
@ -270,6 +310,7 @@ class GroupChatView: UIView {
lazy var reviewBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "IM/review"), for: .normal)
btn.isHidden = true
return btn
}()
@ -809,6 +850,9 @@ final class EmojiSendMsgCell: UITableViewCell {
return v
}()
private var animationName: String?
private var shouldPlayAnimation = false
override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
@ -840,11 +884,43 @@ final class EmojiSendMsgCell: UITableViewCell {
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0
if index < Self.emojiFileNames.count,
let path = Bundle.main.path(forResource: Self.emojiFileNames[index], ofType: "json") {
lottieView.animation = LottieAnimation.filepath(path)
lottieView.play()
guard Self.emojiFileNames.indices.contains(index) else {
animationName = nil
lottieView.animation = nil
return
}
let name = Self.emojiFileNames[index]
animationName = name
shouldPlayAnimation = false
lottieView.stop()
lottieView.animation = nil
lottieView.currentProgress = 0
EmojiPanelCell.loadAnimation(for: name) { [weak self] animation in
guard let self = self, self.animationName == name else { return }
self.lottieView.animation = animation
self.lottieView.currentProgress = 0
if self.shouldPlayAnimation {
self.lottieView.play()
}
}
}
func playAnimation() {
shouldPlayAnimation = true
lottieView.play()
}
func stopAnimation() {
shouldPlayAnimation = false
lottieView.stop()
}
override func prepareForReuse() {
super.prepareForReuse()
animationName = nil
shouldPlayAnimation = false
lottieView.stop()
lottieView.animation = nil
}
private func formatTime(_ t: TimeInterval) -> String {
@ -896,6 +972,9 @@ final class EmojiReceivedMsgCell: UITableViewCell {
return v
}()
private var animationName: String?
private var shouldPlayAnimation = false
override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
@ -933,11 +1012,43 @@ final class EmojiReceivedMsgCell: UITableViewCell {
avatarView.image = msg.avatar
nameLabel.text = msg.senderName
let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0
if index < Self.emojiFileNames.count,
let path = Bundle.main.path(forResource: Self.emojiFileNames[index], ofType: "json") {
lottieView.animation = LottieAnimation.filepath(path)
lottieView.play()
guard Self.emojiFileNames.indices.contains(index) else {
animationName = nil
lottieView.animation = nil
return
}
let name = Self.emojiFileNames[index]
animationName = name
shouldPlayAnimation = false
lottieView.stop()
lottieView.animation = nil
lottieView.currentProgress = 0
EmojiPanelCell.loadAnimation(for: name) { [weak self] animation in
guard let self = self, self.animationName == name else { return }
self.lottieView.animation = animation
self.lottieView.currentProgress = 0
if self.shouldPlayAnimation {
self.lottieView.play()
}
}
}
func playAnimation() {
shouldPlayAnimation = true
lottieView.play()
}
func stopAnimation() {
shouldPlayAnimation = false
lottieView.stop()
}
override func prepareForReuse() {
super.prepareForReuse()
animationName = nil
shouldPlayAnimation = false
lottieView.stop()
lottieView.animation = nil
}
private func formatTime(_ t: TimeInterval) -> String {
@ -955,10 +1066,34 @@ final class EmojiReceivedMsgCell: UITableViewCell {
// MARK: - UICollectionViewDelegate (page control)
extension GroupChatView: UICollectionViewDelegate {
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
guard scrollView == emojiCollectionView else { return }
stopVisibleEmojiAnimations()
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
guard scrollView == emojiCollectionView, !decelerate else { return }
playVisibleEmojiAnimations()
preloadAdjacentEmojiPages()
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
guard scrollView == emojiCollectionView else { return }
let page = Int(scrollView.contentOffset.x / scrollView.bounds.width)
emojiPageControl.currentPage = page
playVisibleEmojiAnimations()
preloadAdjacentEmojiPages()
}
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard collectionView == emojiCollectionView else { return }
guard !collectionView.isDragging, !collectionView.isDecelerating else { return }
(cell as? EmojiPanelCell)?.playAnimation()
}
func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard collectionView == emojiCollectionView else { return }
(cell as? EmojiPanelCell)?.stopAnimation()
}
}

View File

@ -26,7 +26,7 @@ struct DrivingStatsResponse: BaseModelProtocol {
}
}
struct DrivingStatsModel: Mappable {
struct DrivingStatsModel: Mappable, Equatable {
var uuid: String = UUID().uuidString
///
var distance_km: Double = 0
@ -67,3 +67,11 @@ struct DrivingStatsModel: Mappable {
total <- map["total"]
}
}
extension DrivingStatsModel: IdentifiableType {
public typealias Identity = String
public var identity: String {
return uuid
}
}

View File

@ -58,6 +58,10 @@ class GroupMemberListVC: BaseViewController {
self?.rootView.updateTableViewHeight(count: items.first?.items.count ?? 0)
}).disposed(by: disposeBag)
rootView.tableView.rx.modelSelected(ScheduleRecordModel.self)
.subscribe(viewModel.scheduleCellRecordAction.inputs)
.disposed(by: disposeBag)
// +
rootView.selectedDate
.skip(1)
@ -84,12 +88,13 @@ class GroupMemberListVC: BaseViewController {
guard !viewModel.memberId.isEmpty else { return }
let calendar = Calendar.current
let start = calendar.startOfDay(for: date)
let end: Date
if calendar.isDateInToday(date) {
end = Date() //
} else {
end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: date) ?? date
}
let end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: date) ?? date
// let end: Date
// if calendar.isDateInToday(date) {
// end = Date() //
// } else {
// end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: date) ?? date
// }
requestDrivingEvents(start_time: "\(Int64(start.timeIntervalSince1970 * 1000))",
end_time: "\(Int64(end.timeIntervalSince1970 * 1000))")

View File

@ -59,6 +59,15 @@ class GroupMemberListVM {
DrivingEventItem(title: "频繁变道", iconName: "GroupMemberList/6"),
DrivingEventItem(title: "长时间驾驶", iconName: "GroupMemberList/8")
]
lazy var scheduleCellRecordAction: Action<ScheduleRecordModel, Void> = { this in
return Action { model in
let memberInfo = self.memberList.first(where: { $0.user_id == self.memberId })
AppRouter.push(Route.itineraryTrace, userInfo: ["model": model.toJSON(),
"memberInfo": memberInfo?.toJSON() ?? [:]])
return .empty()
}
}(self)
//
func isCurrentUser(id: String) -> Bool {

View File

@ -6,24 +6,759 @@
//
import UIKit
import RxSwift
import RxCocoa
import ObjectMapper
import SwiftyUserDefaults
import AMapNaviKit
import AMapSearchKit
class ItineraryTraceVC: BaseViewController {
fileprivate var rootView: ItineraryTraceView!
private let model: ScheduleRecordModel
private let memberModel: GroupMemberModel
private var routeOverlays: [MAPolyline] = []
private let routeSearch = AMapSearchAPI()
private var drivingSegmentIndexes: [ObjectIdentifier: Int] = [:]
private var playbackSegments: [[CLLocationCoordinate2D]?] = []
private var playbackPath: [(coordinate: CLLocationCoordinate2D, distance: Double)] = []
private var playbackTotalDistance: Double = 0
private var playbackAnnotation: TracePlaybackAnnotation?
private var displayLink: CADisplayLink?
private var playbackProgress: Double = 0
private var playbackLastTick: Date?
private var playbackLastCenterUpdate: Date?
private var playbackDuration: TimeInterval = 30
private let playbackFollowInterval: TimeInterval = 0.2
private let minPlaybackDuration: TimeInterval = 10
private let maxPlaybackDuration: TimeInterval = 180
private let playbackReplaySpeed: CLLocationDistance = 80
private let playbackPathMinStep: CLLocationDistance = 5
private let playbackInterpolationSteps = 3
private var playbackInterpolationFrame = 0
private var playbackPrevCoordinate: CLLocationCoordinate2D?
private var playbackTargetCoordinate: CLLocationCoordinate2D?
private let minPointDistance: CLLocationDistance = 10
private let directSegmentDistance: CLLocationDistance = 80
private let maxDrivingSegmentDistance: CLLocationDistance = 1500
private let maxDrivingSegmentTimeGap: Int64 = 5 * 60 * 1000
private let maxDrivingSegmentRequests = 40
private let eventCalloutTag = 91301
override func loadView() {
rootView = ItineraryTraceView(frame: UIScreen.main.bounds)
view = rootView
}
init(scheduleJson: [String: Any], memberInfo: [String: Any]) {
self.model = ScheduleRecordModel(JSON: scheduleJson) ?? ScheduleRecordModel(JSON: ["id": ""])!
self.memberModel = GroupMemberModel(JSON: memberInfo) ?? GroupMemberModel(JSON: ["id": ""])!
print(self.model)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
rootView.eventCollectionView.dataSource = self
rootView.eventCollectionView.delegate = self
setupMap()
populateData()
addAnnotations()
drawRoute()
setupPlaybackControls()
}
/*
// MARK: - Navigation
// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// Get the new view controller using segue.destination.
// Pass the selected object to the new view controller.
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent || isBeingDismissed {
stopPlayback(resetProgress: false)
rootView.cleanupMap()
}
}
*/
@MainActor deinit {
displayLink?.invalidate()
}
private func populateData() {
rootView.titleLab.text = "\(memberModel.nick_name) 的驾驶详细信息"
rootView.startTimeLab.text = model.start_time.isoStringToCustom(model.start_time, format: "HH:mm")
rootView.endTimeLab.text = model.end_time.isoStringToCustom(model.end_time, format: "HH:mm")
rootView.startAddressLab.text = model.start_address?.street
rootView.endAddressLab.text = model.end_address?.street
}
// MARK: - Map
private func setupMap() {
rootView.mapView.delegate = self
routeSearch?.delegate = self
if let lat = Defaults[\.currentLatitude], let lon = Defaults[\.currentLongitude] {
let coord = CLLocationCoordinate2D(latitude: lat, longitude: lon)
if CLLocationCoordinate2DIsValid(coord) {
rootView.mapView.setCenter(coord, animated: false)
rootView.mapView.setZoomLevel(14, animated: false)
}
}
}
private func addAnnotations() {
//
if let start = model.trajectory_path.first, abs(start.lat) > 0.0001 {
let ann = MAPointAnnotation()
ann.coordinate = CLLocationCoordinate2D(latitude: start.lat, longitude: start.lng)
ann.title = "start"
rootView.mapView.addAnnotation(ann)
}
//
if let end = model.trajectory_path.last, abs(end.lat) > 0.0001 {
let ann = MAPointAnnotation()
ann.coordinate = CLLocationCoordinate2D(latitude: end.lat, longitude: end.lng)
ann.title = "end"
rootView.mapView.addAnnotation(ann)
}
// driving_events
for ev in model.driving_events {
guard abs(ev.latitude) > 0.0001 else { continue }
let ann = TraceEventAnnotation()
ann.coordinate = CLLocationCoordinate2D(latitude: ev.latitude, longitude: ev.longitude)
ann.title = "event"
ann.eventType = ev.event_type
ann.eventTime = eventTimeText(ev.timestamp)
ann.eventAddress = ev.address?.street ?? ""
rootView.mapView.addAnnotation(ann)
}
//
if let anns = rootView.mapView.annotations, anns.count > 0 {
rootView.mapView.showAnnotations(anns, animated: true)
}
}
private func drawRoute() {
clearRouteOverlays()
let points = cleanedTracePoints(model.trajectory_path)
guard points.count >= 2 else { print("[trace] insufficient points: \(points.count)"); return }
playbackSegments = Array(repeating: nil, count: points.count - 1)
var drivingRequestCount = 0
for i in 0..<(points.count - 1) {
let from = points[i]
let to = points[i + 1]
let fallback = [from.coordinate, to.coordinate]
let distance = from.coordinate.distance(to: to.coordinate)
let timeGap = abs(to.timestamp - from.timestamp)
if distance < directSegmentDistance {
addPolyline(fallback)
playbackSegments[i] = fallback
} else if distance <= maxDrivingSegmentDistance,
timeGap <= maxDrivingSegmentTimeGap,
drivingRequestCount < maxDrivingSegmentRequests {
requestDrivingSegment(from: from.coordinate, to: to.coordinate, segmentIndex: i)
drivingRequestCount += 1
} else {
// /
continue
}
}
buildPlaybackPath()
updatePlaybackAnnotation(progress: playbackProgress)
print("[trace] cleaned=\(points.count) drivingRequests=\(drivingRequestCount)")
}
private func buildPlaybackPath() {
var path: [(coordinate: CLLocationCoordinate2D, distance: Double)] = []
var cumulative: Double = 0
var lastCoord: CLLocationCoordinate2D?
for segment in playbackSegments.compactMap({ $0 }) where segment.count > 1 {
for coord in segment {
if let last = lastCoord {
let d = last.distance(to: coord)
if d < playbackPathMinStep { continue }
cumulative += d
}
path.append((coordinate: coord, distance: cumulative))
lastCoord = coord
}
}
playbackPath = path
playbackTotalDistance = cumulative
}
private func clearRouteOverlays() {
for overlay in routeOverlays {
rootView.mapView.remove(overlay)
}
routeOverlays.removeAll()
drivingSegmentIndexes.removeAll()
}
private func cleanedTracePoints(_ points: [TrackPoint]) -> [(timestamp: Int64, coordinate: CLLocationCoordinate2D)] {
let sorted = points.sorted { $0.timestamp < $1.timestamp }
var result: [(timestamp: Int64, coordinate: CLLocationCoordinate2D)] = []
for point in sorted {
let coordinate = CLLocationCoordinate2D(latitude: point.lat, longitude: point.lng)
guard abs(point.lat) > 0.0001,
abs(point.lng) > 0.0001,
CLLocationCoordinate2DIsValid(coordinate) else { continue }
if let last = result.last,
last.coordinate.distance(to: coordinate) < minPointDistance {
continue
}
result.append((timestamp: point.timestamp, coordinate: coordinate))
}
return result
}
private func requestDrivingSegment(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D, segmentIndex: Int) {
let request = AMapDrivingRouteSearchRequest()
request.origin = AMapGeoPoint.location(withLatitude: CGFloat(from.latitude), longitude: CGFloat(from.longitude))
request.destination = AMapGeoPoint.location(withLatitude: CGFloat(to.latitude), longitude: CGFloat(to.longitude))
request.strategy = 0
drivingSegmentIndexes[ObjectIdentifier(request)] = segmentIndex
routeSearch?.aMapDrivingRouteSearch(request)
}
private func setupPlaybackControls() {
rootView.playBtn.addTarget(self, action: #selector(playButtonTapped), for: .touchUpInside)
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchDown), for: .touchDown)
rootView.progressSlider.addTarget(self, action: #selector(progressSliderValueChanged), for: .valueChanged)
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchUpInside)
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchUpOutside)
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchCancel)
}
@objc private func playButtonTapped() {
if !rootView.playBtn.isSelected, playbackProgress >= 1 {
playbackProgress = 0
rootView.progressSlider.value = 0
updatePlaybackAnnotation(progress: playbackProgress)
}
rootView.playBtn.isSelected ? pausePlayback() : startPlayback()
}
@objc private func progressSliderTouchDown() {
pausePlayback()
}
@objc private func progressSliderValueChanged() {
playbackProgress = Double(rootView.progressSlider.value)
updatePlaybackAnnotation(progress: playbackProgress)
}
@objc private func progressSliderTouchEnded() {
playbackProgress = Double(rootView.progressSlider.value)
updatePlaybackAnnotation(progress: playbackProgress)
}
private func startPlayback() {
let totalDistance = playbackPathDistance()
guard totalDistance > 0 else { return }
guard playbackProgress < 1 else { return }
playbackDuration = max(minPlaybackDuration, min(maxPlaybackDuration, totalDistance / playbackReplaySpeed))
rootView.playBtn.isSelected = true
playbackLastTick = Date()
playbackLastCenterUpdate = nil
playbackInterpolationFrame = playbackInterpolationSteps
if rootView.mapView.zoomLevel < 16 {
rootView.mapView.setZoomLevel(16, animated: false)
}
displayLink?.invalidate()
displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink))
displayLink?.add(to: .main, forMode: .common)
}
private func pausePlayback() {
rootView.playBtn.isSelected = false
displayLink?.invalidate()
displayLink = nil
playbackLastTick = nil
playbackLastCenterUpdate = nil
}
private func stopPlayback(resetProgress: Bool) {
pausePlayback()
if resetProgress {
playbackProgress = 0
rootView.progressSlider.value = 0
updatePlaybackAnnotation(progress: playbackProgress)
}
}
@objc private func handleDisplayLink() {
let now = Date()
let delta = playbackLastTick.map { now.timeIntervalSince($0) } ?? 0
playbackLastTick = now
playbackProgress = min(1, playbackProgress + delta / playbackDuration)
rootView.progressSlider.value = Float(playbackProgress)
playbackInterpolationFrame += 1
if playbackInterpolationFrame >= playbackInterpolationSteps {
playbackInterpolationFrame = 0
playbackPrevCoordinate = playbackTargetCoordinate
playbackTargetCoordinate = playbackCoordinate(at: playbackProgress)
}
if let target = playbackTargetCoordinate {
if let prev = playbackPrevCoordinate {
let t = Double(playbackInterpolationFrame) / Double(playbackInterpolationSteps)
let coord = prev.interpolate(to: target, ratio: t)
setPlaybackAnnotationCoordinate(coord)
followPlaybackCoordinateIfNeeded(coord)
} else {
setPlaybackAnnotationCoordinate(target)
followPlaybackCoordinateIfNeeded(target)
}
}
if playbackProgress >= 1 {
finishPlayback()
}
}
private func finishPlayback() {
playbackProgress = 1
rootView.progressSlider.value = 1
updatePlaybackAnnotation(progress: playbackProgress)
pausePlayback()
}
private func updatePlaybackAnnotation(progress: Double, interpolated: Bool = false) {
guard let coordinate = playbackCoordinate(at: progress) else { return }
if !interpolated {
playbackPrevCoordinate = coordinate
playbackTargetCoordinate = coordinate
playbackInterpolationFrame = playbackInterpolationSteps
}
setPlaybackAnnotationCoordinate(coordinate)
followPlaybackCoordinateIfNeeded(coordinate)
}
private func setPlaybackAnnotationCoordinate(_ coordinate: CLLocationCoordinate2D) {
if let annotation = playbackAnnotation {
annotation.coordinate = coordinate
} else {
let annotation = TracePlaybackAnnotation()
annotation.coordinate = coordinate
playbackAnnotation = annotation
rootView.mapView.addAnnotation(annotation)
}
}
private func followPlaybackCoordinateIfNeeded(_ coordinate: CLLocationCoordinate2D) {
guard rootView.playBtn.isSelected else { return }
let now = Date()
if let lastUpdate = playbackLastCenterUpdate,
now.timeIntervalSince(lastUpdate) < playbackFollowInterval {
return
}
playbackLastCenterUpdate = now
rootView.mapView.setCenter(coordinate, animated: true)
}
private func playbackCoordinate(at progress: Double) -> CLLocationCoordinate2D? {
let clampedProgress = min(max(progress, 0), 1)
let path = playbackPath
guard !path.isEmpty, playbackTotalDistance > 0 else { return path.first?.coordinate }
let targetDistance = playbackTotalDistance * clampedProgress
var lo = 0, hi = path.count - 1
while lo < hi {
let mid = (lo + hi) / 2
if path[mid].distance < targetDistance {
lo = mid + 1
} else {
hi = mid
}
}
let idx = lo
let prev = idx > 0 ? path[idx - 1] : path[0]
let curr = path[idx]
let segDist = curr.distance - prev.distance
let ratio = segDist > 0 ? max(0, min(1, (targetDistance - prev.distance) / segDist)) : 0
return prev.coordinate.interpolate(to: curr.coordinate, ratio: ratio)
}
private func playbackPathDistance() -> CLLocationDistance {
playbackTotalDistance
}
@discardableResult
private func addPolyline(_ coordinates: [CLLocationCoordinate2D]) -> MAPolyline? {
guard coordinates.count > 1 else { return nil }
var mutableCoords = coordinates
guard let polyline = MAPolyline(coordinates: &mutableCoords, count: UInt(mutableCoords.count)) else { return nil }
rootView.mapView.add(polyline)
routeOverlays.append(polyline)
return polyline
}
private func coordinates(from path: AMapPath) -> [CLLocationCoordinate2D] {
var coords: [CLLocationCoordinate2D] = []
for step in path.steps {
guard let polylineStr = step.polyline else { continue }
for point in polylineStr.components(separatedBy: ";") {
let latLon = point.components(separatedBy: ",")
if latLon.count == 2,
let lon = Double(latLon[0]),
let lat = Double(latLon[1]) {
let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
if CLLocationCoordinate2DIsValid(coordinate) {
coords.append(coordinate)
}
}
}
}
return coords
}
private func eventTimeText(_ timestamp: Int64) -> String {
let seconds = timestamp > 10_000_000_000 ? Double(timestamp) / 1000.0 : Double(timestamp)
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return formatter.string(from: Date(timeIntervalSince1970: seconds))
}
private static func playbackAvatarImage(_ image: UIImage) -> UIImage? {
let size = CGSize(width: 34, height: 34)
UIGraphicsBeginImageContextWithOptions(size, false, 0)
defer { UIGraphicsEndImageContext() }
let rect = CGRect(origin: .zero, size: size)
UIColor.white.setFill()
UIBezierPath(ovalIn: rect).fill()
let imageRect = rect.insetBy(dx: 3, dy: 3)
let clipPath = UIBezierPath(ovalIn: imageRect)
clipPath.addClip()
image.draw(in: imageRect)
return UIGraphicsGetImageFromCurrentImageContext()
}
}
private extension CLLocationCoordinate2D {
func distance(to coordinate: CLLocationCoordinate2D) -> CLLocationDistance {
let fromLocation = CLLocation(latitude: latitude, longitude: longitude)
let toLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
return fromLocation.distance(from: toLocation)
}
func interpolate(to coordinate: CLLocationCoordinate2D, ratio: Double) -> CLLocationCoordinate2D {
let clampedRatio = min(max(ratio, 0), 1)
return CLLocationCoordinate2D(latitude: latitude + (coordinate.latitude - latitude) * clampedRatio,
longitude: longitude + (coordinate.longitude - longitude) * clampedRatio)
}
}
private extension Array where Element == CLLocationCoordinate2D {
var totalDistance: CLLocationDistance {
guard count > 1 else { return 0 }
var distance: CLLocationDistance = 0
for i in 0..<(count - 1) {
distance += self[i].distance(to: self[i + 1])
}
return distance
}
}
private final class TracePlaybackAnnotation: MAPointAnnotation {}
// MARK: - MAMapViewDelegate
extension ItineraryTraceVC: MAMapViewDelegate {
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
guard !(annotation is MAUserLocation) else { return nil }
guard let pointAnn = annotation as? MAPointAnnotation else { return nil }
if annotation is TracePlaybackAnnotation {
let id = "PlaybackUserPin"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
else { view?.annotation = annotation }
view?.image = Self.playbackAvatarImage(memberModel.userIcon)
view?.centerOffset = CGPoint(x: 0, y: -16)
return view
}
if pointAnn.title == "start" {
let id = "StartPin"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
else { view?.annotation = annotation }
view?.image = UIImage(named: "ItineraryTrace/start")
view?.centerOffset = CGPoint(x: 0, y: -12)
return view
}
if pointAnn.title == "end" {
let id = "EndPin"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
else { view?.annotation = annotation }
view?.image = UIImage(named: "ItineraryTrace/end")
view?.centerOffset = CGPoint(x: 0, y: -12)
return view
}
if pointAnn.title == "event" {
let id = "EventPin"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
else { view?.annotation = annotation }
view?.viewWithTag(eventCalloutTag)?.removeFromSuperview()
view?.image = UIImage(named: "ItineraryTrace/event")
view?.bounds.size = CGSize(width: 20, height: 20)
view?.clipsToBounds = false
return view
}
return nil
}
func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) {
guard let annotation = view.annotation as? TraceEventAnnotation else { return }
view.viewWithTag(eventCalloutTag)?.removeFromSuperview()
let calloutWidth = TraceEventCalloutView.preferredWidth(type: annotation.eventType,
time: annotation.eventTime,
address: annotation.eventAddress)
let callout = TraceEventCalloutView(frame: CGRect(x: 0, y: 0, width: calloutWidth, height: 72))
callout.tag = eventCalloutTag
callout.configure(icon: annotation.eventTypeIcon,
type: annotation.eventTypeText,
time: annotation.eventTime,
address: annotation.eventAddress)
callout.frame.origin = CGPoint(x: (view.bounds.width - callout.bounds.width) / 2, y: -callout.bounds.height - 8)
view.addSubview(callout)
}
func mapView(_ mapView: MAMapView!, didDeselect view: MAAnnotationView!) {
view.viewWithTag(eventCalloutTag)?.removeFromSuperview()
}
func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! {
if let polyline = overlay as? MAPolyline {
let r = MAPolylineRenderer(polyline: polyline)
r?.strokeColor = UIColor(hexStr: "#16B3FF")
r?.lineWidth = 3
r?.lineJoinType = kMALineJoinRound
r?.lineCapType = kMALineCapRound
return r
}
return nil
}
}
// MARK: - AMapSearchDelegate
extension ItineraryTraceVC: AMapSearchDelegate {
func onRouteSearchDone(_ request: AMapRouteSearchBaseRequest!, response: AMapRouteSearchResponse!) {
let requestId = ObjectIdentifier(request)
let segmentIndex = drivingSegmentIndexes.removeValue(forKey: requestId)
guard let path = response.route?.paths?.first as? AMapPath else {
return
}
let coords = coordinates(from: path)
if coords.count > 1 {
addPolyline(coords)
if let segmentIndex, playbackSegments.indices.contains(segmentIndex) {
playbackSegments[segmentIndex] = coords
buildPlaybackPath()
updatePlaybackAnnotation(progress: playbackProgress)
}
}
}
func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) {
if let request = request as? AMapRouteSearchBaseRequest {
let requestId = ObjectIdentifier(request)
drivingSegmentIndexes.removeValue(forKey: requestId)
}
print("Trace route search error: \(error.localizedDescription)")
}
}
// MARK: - UICollectionViewDataSource & Delegate ()
extension ItineraryTraceVC: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 8
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell: ItineraryTraceCell = collectionView.dequeueReusableCell(for: indexPath)
guard let stats = model.statistics else { return cell }
let all: [(String, String, Int)] = [
("急加速", "GroupMemberList/1", stats.hard_acceleration),
("急转向", "GroupMemberList/3", stats.sharp_turn),
("急刹", "GroupMemberList/5", stats.hard_braking),
("手机干扰", "GroupMemberList/7", stats.signal_loss),
("超速", "GroupMemberList/2", stats.speeding),
("低速", "GroupMemberList/4", stats.low_speeding),
("频繁变道", "GroupMemberList/6", stats.frequent_lane_change),
("长时间驾驶", "GroupMemberList/8", stats.long_driving)
]
let item = all[indexPath.row]
cell.iconView.image = UIImage(named: item.1)
cell.nameLab.text = item.0
cell.countLab.text = "\(item.2)"
return cell
}
}
// MARK: -
private final class TraceEventAnnotation: MAPointAnnotation {
var eventType = ""
var eventTypeText: String {
get {
switch eventType {
case "hard_acceleration":
return "急加速"
case "sharp_turn":
return "急转向"
case "hard_braking":
return "急刹"
case "signal_loss":
return "手机干扰"
case "speeding":
return "超速"
case "low_speeding":
return "低速"
case "frequent_lane_change":
return "频繁变道"
case "long_driving":
return "长时间驾驶"
default:
return eventType
}
}
}
var eventTypeIcon: UIImage? {
get {
switch eventType {
case "hard_acceleration":
return UIImage(named: "GroupMemberList/1")
case "sharp_turn":
return UIImage(named: "GroupMemberList/3")
case "hard_braking":
return UIImage(named: "GroupMemberList/5")
case "signal_loss":
return UIImage(named: "GroupMemberList/7")
case "speeding":
return UIImage(named: "GroupMemberList/2")
case "low_speeding":
return UIImage(named: "GroupMemberList/4")
case "frequent_lane_change":
return UIImage(named: "GroupMemberList/6")
case "long_driving":
return UIImage(named: "GroupMemberList/8")
default:
return nil
}
}
}
var eventTime = ""
var eventAddress = ""
}
private final class TraceEventCalloutView: UIView {
private let bubbleView = UIView()
private let arrowView = UIView()
private let typeIcon: UIImageView = {
let view = UIImageView()
view.contentMode = .scaleAspectFill
return view
}()
private let typeLab: UILabel = {
let lab = UILabel()
lab.font = .systemFont(ofSize: 14, weight: .medium)
lab.textColor = UIColor(hexStr: "#333333")
lab.numberOfLines = 1
return lab
}()
private let detailLab: UILabel = {
let lab = UILabel()
lab.font = .systemFont(ofSize: 12)
lab.textColor = UIColor(hexStr: "#666666")
lab.numberOfLines = 1
return lab
}()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
bubbleView.backgroundColor = .white
bubbleView.layer.cornerRadius = 8
bubbleView.layer.shadowColor = UIColor.black.withAlphaComponent(0.16).cgColor
bubbleView.layer.shadowOpacity = 1
bubbleView.layer.shadowOffset = CGSize(width: 0, height: 2)
bubbleView.layer.shadowRadius = 8
arrowView.backgroundColor = .white
arrowView.transform = CGAffineTransform(rotationAngle: .pi / 4)
addSubview(bubbleView)
addSubview(arrowView)
bubbleView.addSubview(typeIcon)
bubbleView.addSubview(typeLab)
bubbleView.addSubview(detailLab)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let arrowSize: CGFloat = 12
bubbleView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: bounds.height - arrowSize)
arrowView.bounds = CGRect(x: 0, y: 0, width: arrowSize, height: arrowSize)
arrowView.center = CGPoint(x: bounds.midX, y: bubbleView.frame.maxY - 1)
typeIcon.frame = CGRect(x: 9, y: 12, width: 16, height: 16)
typeLab.frame = CGRect(x: 28, y: 10, width: bubbleView.bounds.width - 24, height: 20)
detailLab.frame = CGRect(x: 9, y: 34, width: bubbleView.bounds.width - 24, height: 18)
}
func configure(icon: UIImage?, type: String, time: String, address: String) {
if let img = icon {
typeIcon.image = img
}
typeLab.text = type
let detail = address.isEmpty ? time : "\(time)\(address)"
let attr = NSMutableAttributedString(string: detail)
attr.addAttributes([
.foregroundColor: UIColor(hexStr: "#16B3FF"),
.font: UIFont.systemFont(ofSize: 12, weight: .medium)
], range: NSRange(location: 0, length: min(time.count, detail.count)))
detailLab.attributedText = attr
}
static func preferredWidth(type: String, time: String, address: String) -> CGFloat {
let title = type.isEmpty ? "驾驶事件" : type
let detail = address.isEmpty ? time : "\(time)\(address)"
let titleWidth = (title as NSString).size(withAttributes: [.font: UIFont.systemFont(ofSize: 14, weight: .medium)]).width
let detailWidth = (detail as NSString).size(withAttributes: [.font: UIFont.systemFont(ofSize: 12)]).width + 5
return min(max(max(titleWidth, detailWidth) + 24, 120), 280)
}
}

View File

@ -28,8 +28,11 @@ class ItineraryTraceView: UIView {
navBarView.addSubview(backBtn)
navBarView.addSubview(navTitleLabel)
addSubview(playView)
addSubview(bottomView)
mapView.layoutChain
.edges()
.edges(excludingEdge: .bottom)
navBgView.layoutChain
.edges(excludingEdge: .bottom)
@ -48,7 +51,17 @@ class ItineraryTraceView: UIView {
.top(kStatusBarHeight + 12)
.centerX()
bottomView.layoutChain
.edgesHorzontal()
.bottom()
.height(320 + kSafeBottomMargin)
playView.layoutChain
.bottomToTopOfView(bottomView, offset: -10)
.edgesHorzontal(20)
.height(35)
mapView.layoutChain.bottomToTopOfView(bottomView, offset: 20)
}
// MARK: - Views
@ -129,14 +142,104 @@ class ItineraryTraceView: UIView {
.centerY(startTimeLab)
.leftToRightOfView(dotView, offset: 12)
let dashedLine = UIImageView(image: UIImage(named: "ItineraryTrace/dashed"))
view.addSubview(dashedLine)
dashedLine.layoutChain
.topToBottomOfView(dotView, offset: 7)
.width(2)
.height(22)
.centerX(dotView)
view.addSubview(endTimeLab)
view.addSubview(endAddressLab)
let endIcon = UIImageView(image: UIImage(named: "ItineraryTrace/end_small"))
view.addSubview(endIcon)
endIcon.layoutChain
.width(10)
.height(12)
.centerX(dashedLine)
.centerY(endTimeLab)
endTimeLab.layoutChain
.topToBottomOfView(startTimeLab, offset: 24)
.leftToView(startTimeLab)
endAddressLab.layoutChain
.leftToRightOfView(endIcon, offset: 10)
.centerY(endTimeLab)
let eventTitleLab = UILabel()
eventTitleLab.text = "行程事件"
eventTitleLab.textColor = UIColor(hexStr: "#0F2846")
eventTitleLab.font = .systemFont(ofSize: 16, weight: .semibold)
view.addSubview(eventTitleLab)
eventTitleLab.layoutChain
.topToBottomOfView(endTimeLab, offset: 15)
.left(20)
view.addSubview(eventCollectionView)
eventCollectionView.layoutChain
.topToBottomOfView(eventTitleLab, offset: 10)
.edgesHorzontal()
.bottom(kSafeBottomMargin)
return view
}()
///
lazy var playView: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.layer.shadowColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.25).cgColor
view.layer.shadowOffset = CGSize(width: 0, height: 2)
view.layer.shadowOpacity = 1
view.layer.shadowRadius = 4
let cornerRadiusView = UIView()
cornerRadiusView.backgroundColor = .white
cornerRadiusView.cornerRadius = 6
view.addSubview(cornerRadiusView)
cornerRadiusView.layoutChain.edges()
cornerRadiusView.addSubview(playBtn)
cornerRadiusView.addSubview(progressSlider)
playBtn.layoutChain
.left(12)
.centerY()
.width(20)
.height(20)
progressSlider.layoutChain
.leftToRightOfView(playBtn, offset: 12)
.right(12)
.centerY()
.height(30)
return view
}()
lazy var playBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "ItineraryTrace/play"), for: .normal)
btn.setImage(UIImage(named: "ItineraryTrace/pause"), for: .selected)
btn.adjustsImageWhenHighlighted = false
return btn
}()
lazy var progressSlider: UISlider = {
let slider = UISlider()
slider.minimumValue = 0
slider.maximumValue = 1
slider.value = 0
slider.minimumTrackTintColor = UIColor(hexStr: "#16B3FF")
slider.maximumTrackTintColor = UIColor(hexStr: "#E5E5E5")
slider.setThumbImage(UIImage(named: "ItineraryTrace/slider"), for: .normal)
slider.setThumbImage(UIImage(named: "ItineraryTrace/slider"), for: .highlighted)
return slider
}()
/// xxx
lazy var titleLab: UILabel = {
let label = UILabel()
@ -147,6 +250,7 @@ class ItineraryTraceView: UIView {
lazy var startTimeLab: UILabel = {
let label = UILabel()
label.text = " "
label.textColor = UIColor(hexStr: "#666666")
label.font = .systemFont(ofSize: 16, weight: .medium)
return label
@ -154,6 +258,7 @@ class ItineraryTraceView: UIView {
lazy var startAddressLab: UILabel = {
let label = UILabel()
label.text = " "
label.textColor = UIColor(hexStr: "#999999")
label.font = .systemFont(ofSize: 14, weight: .medium)
return label
@ -161,6 +266,7 @@ class ItineraryTraceView: UIView {
lazy var endTimeLab: UILabel = {
let label = UILabel()
label.text = " "
label.textColor = UIColor(hexStr: "#666666")
label.font = .systemFont(ofSize: 16, weight: .medium)
return label
@ -168,11 +274,36 @@ class ItineraryTraceView: UIView {
lazy var endAddressLab: UILabel = {
let label = UILabel()
label.text = " "
label.textColor = UIColor(hexStr: "#999999")
label.font = .systemFont(ofSize: 14, weight: .medium)
return label
}()
//
lazy var eventCollectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.itemSize = CGSize(width: 65, height: 68)
layout.minimumLineSpacing = 15
layout.scrollDirection = .vertical
layout.sectionInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
cv.backgroundColor = .clear
cv.showsHorizontalScrollIndicator = false
cv.isScrollEnabled = false
cv.register(ItineraryTraceCell.self)
return cv
}()
func cleanupMap() {
#if !targetEnvironment(simulator)
mapView?.delegate = nil
mapView?.removeFromSuperview()
mapView = nil
#endif
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
@ -184,3 +315,62 @@ class ItineraryTraceView: UIView {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - ItineraryTraceCell
class ItineraryTraceCell: UICollectionViewCell {
func configure(_ item: DrivingEventItem) {
nameLab.text = item.title
countLab.text = item.count.string
}
private let bgView: UIView = {
let v = UIView()
v.backgroundColor = UIColor(hexStr: "#EEF9FF")
v.cornerRadius = 10
return v
}()
lazy var iconView: UIImageView = {
let view = UIImageView()
view.contentMode = .scaleAspectFill
return view
}()
lazy var nameLab: UILabel = {
let l = UILabel()
l.font = .systemFont(ofSize: 12, weight: .medium)
l.textColor = UIColor(hexStr: "#333333")
l.textAlignment = .center
return l
}()
lazy var countLab: UILabel = {
let l = UILabel()
l.font = .systemFont(ofSize: 14, weight: .medium)
l.textColor = UIColor(hexStr: "#38ABFF")
l.textAlignment = .center
return l
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(bgView)
bgView.addSubview(countLab)
contentView.addSubview(nameLab)
bgView.layoutChain
.top()
.centerX()
.width(50)
.height(50)
countLab.layoutChain.edges()
nameLab.layoutChain
.topToBottomOfView(bgView, offset: 6)
.edgesHorzontal()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}

View File

@ -41,11 +41,11 @@ struct ScheduleRecordModel: Mappable, Equatable {
///
var end_time: String = ""
///
var distance_km: String = ""
var distance_km: Double = 0
///
var duration_minutes: Int = 0
///
var avg_speed: String = ""
var avg_speed: Double = 0
///
var max_speed: Double = 0
///
@ -58,7 +58,7 @@ struct ScheduleRecordModel: Mappable, Equatable {
var trajectory_path: [TrackPoint] = []
var driving_events: [TrackEvent] = []
var stay_points: [StayPoint] = []
var statistics: TripStatistics?
var statistics: DrivingStatsModel?
init?(map: Map) {}
@ -86,7 +86,7 @@ extension ScheduleRecordModel: IdentifiableType {
public typealias Identity = String
public var identity: String {
return id
return uuid
}
}
@ -100,8 +100,8 @@ struct TripLocation: Mappable, Equatable {
init?(map: Map) {}
mutating func mapping(map: Map) {
lat <- map["lat"]
lng <- map["lng"]
lat <- map["latitude"]
lng <- map["longitude"]
}
}
@ -153,8 +153,8 @@ struct TrackPoint: Mappable, Equatable {
init?(map: Map) {}
mutating func mapping(map: Map) {
lat <- map["lat"]
lng <- map["lng"]
lat <- map["location.latitude"]
lng <- map["location.longitude"]
timestamp <- map["timestamp"]
}
}
@ -170,16 +170,23 @@ extension TrackPoint: IdentifiableType {
struct TrackEvent: Mappable, Equatable {
var uuid: String = UUID().uuidString
var type: String = ""
var event_type: String = ""
var timestamp: Int64 = 0
var value: Int = 0
var latitude: Double = 0
var longitude: Double = 0
var address: TripAddress?
init?(map: Map) {}
mutating func mapping(map: Map) {
type <- map["type"]
event_type <- map["event_type"]
timestamp <- map["timestamp"]
value <- map["value"]
latitude <- map["location.latitude"]
longitude <- map["location.longitude"]
address <- map["address"]
}
}
@ -203,8 +210,8 @@ struct StayPoint: Mappable, Equatable {
init?(map: Map) {}
mutating func mapping(map: Map) {
lat <- map["lat"]
lng <- map["lng"]
lat <- map["latitude"]
lng <- map["longitude"]
start_time <- map["start_time"]
end_time <- map["end_time"]
address <- map["address"]

View File

@ -18,7 +18,7 @@ class GroupListPopView: UIView {
defaultGroupKey = model.default_group_key
groupList = model.groups
let viewHeight = CGFloat(model.groups.count * 68)
let viewHeight = CGFloat(model.groups.prefix(5).count * 68)
tableView.layoutChain.height(viewHeight, relation: .greaterThanOrEqual)
}
}
@ -101,7 +101,6 @@ class GroupListPopView: UIView {
tableView.estimatedRowHeight = 68
tableView.showsVerticalScrollIndicator = false
tableView.bounces = false
tableView.isScrollEnabled = false
tableView.register(GroupListCell.self)
tableView.tableHeaderView = UIView(frame: CGRectMake(0, 0, kScreenWidth, 10))
tableView.dataSource = self

View File

@ -165,6 +165,7 @@ class GroupMemberCell: UITableViewCell {
nameLab.textColor = UIColor(hexStr: isCurrentUser ? "#16B3FF" : "#0F2846")
//
batteryInfoView.isHidden = true
if AppContextManager.shared.vip > 1, model.is_online {
batteryInfoView.isHidden = model.battery.int == 0
// 16

View File

@ -43,6 +43,10 @@ class HomeView: UIView {
/// tableView
private var isSubCanScroll = false
private var groupMemberTableTopOffset: CGFloat {
-groupMemberView.tableView.adjustedContentInset.top
}
// MARK: - Map
#if !targetEnvironment(simulator)
lazy var mapView: MAMapView! = {
@ -54,7 +58,7 @@ class HomeView: UIView {
mv.showsScale = false
mv.isRotateEnabled = false
mv.isRotateCameraEnabled = false
DispatchQueue.main.async { mv.logoCenter = CGPoint(x: mv.bounds.width - 55, y: kStatusBarHeight + 30) }
DispatchQueue.main.async { mv.logoCenter = CGPoint(x: 15, y: mv.bounds.height / 2) }
return mv
}()
#else
@ -89,14 +93,7 @@ class HomeView: UIView {
.disposed(by: disposeBag)
// contentOffset scroll pan handler + panVelocity
groupMemberView.tableView.rx.contentOffset
.subscribe(onNext: { [weak self] offset in
guard let self = self else { return }
if !self.isSubCanScroll && offset.y != 0 {
self.groupMemberView.tableView.setContentOffset(.zero, animated: false)
}
})
groupMemberView.tableView.rx.setDelegate(self)
.disposed(by: disposeBag)
//
@ -226,6 +223,7 @@ class HomeView: UIView {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleGroupMemberPan(_:)))
pan.delegate = self
groupMemberView.addGestureRecognizer(pan)
groupMemberView.tableView.isScrollEnabled = false
// quickMessageView interactionView
addSubview(interactionView)
@ -288,21 +286,23 @@ class HomeView: UIView {
switch pan.state {
case .began:
layoutIfNeeded()
panStartTop = groupMemberView.frame.minY
case .changed:
let newTop = panStartTop + pan.translation(in: self).y
let scrollOffset = groupMemberView.tableView.contentOffset.y
// tableView
// tableView view
if isSubCanScroll {
let tableViewOffset = self.groupMemberView.tableView.contentOffset.y
if tableViewOffset > 0 {
if scrollOffset > groupMemberTableTopOffset + 0.5 {
// GroupMemberView
return
}
// Pan velocity > 0
if pan.velocity(in: self).y > 0 || groupMemberView.frame.minY > groupMemberUpLimit + 1 {
//
isSubCanScroll = false
panStartTop = groupMemberView.frame.minY
}
@ -323,23 +323,28 @@ class HomeView: UIView {
let velocity = pan.velocity(in: self)
let isNearUp = abs(groupMemberView.frame.minY - groupMemberUpLimit) < abs(groupMemberView.frame.minY - groupMemberDownLimit)
let target: CGFloat
if abs(velocity.y) > 200 {
if groupMemberView.frame.minY <= groupMemberUpLimit + 5 {
// tableView
target = isNearUp ? groupMemberUpLimit : groupMemberDownLimit
} else if abs(velocity.y) > 200 {
target = velocity.y < 0 ? groupMemberUpLimit : groupMemberDownLimit
} else {
target = isNearUp ? groupMemberUpLimit : groupMemberDownLimit
}
topConstraint.constant = target - groupMemberDownLimit + 10
let atTop = target == self.groupMemberUpLimit
if !atTop {
isSubCanScroll = false
groupMemberView.tableView.isScrollEnabled = false
groupMemberView.tableView.setContentOffset(CGPoint(x: 0, y: groupMemberTableTopOffset), animated: false)
}
isSubCanScroll = atTop
UIView.animate(withDuration: 0.25, delay: 0,
options: [.curveEaseOut, .allowUserInteraction]) {
self.layoutIfNeeded()
} completion: { _ in
let atTop = target == self.groupMemberUpLimit
self.isSubCanScroll = atTop
// tableView
if !atTop {
self.groupMemberView.tableView.contentOffset.y = 0
}
self.groupMemberView.tableView.isScrollEnabled = atTop
}
default:
@ -351,8 +356,9 @@ class HomeView: UIView {
func dismissGroupMemberView() {
guard isGroupMemberLimitsSet, let topConstraint = groupMemberTopConstraint else { return }
isSubCanScroll = false
groupMemberView.tableView.isScrollEnabled = false
topConstraint.constant = groupMemberDownLimit - groupMemberDownLimit + 10
groupMemberView.tableView.setContentOffset(.zero, animated: false)
groupMemberView.tableView.setContentOffset(CGPoint(x: 0, y: groupMemberTableTopOffset), animated: false)
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) {
self.layoutIfNeeded()
}
@ -412,7 +418,8 @@ class HomeView: UIView {
view.backgroundColor = .white
view.cornerRadius = 18
view.clipsToBounds = false
view.isHidden = true
view.addSubview(messageIcon)
view.addSubview(messageDotView)
@ -830,6 +837,28 @@ class HomeView: UIView {
}()
}
// MARK: - UIScrollViewDelegate
extension HomeView: UIScrollViewDelegate {
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
guard scrollView == groupMemberView.tableView else { return }
if scrollView.contentOffset.y > groupMemberTableTopOffset {
isSubCanScroll = true
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView == groupMemberView.tableView else { return }
if isSubCanScroll {
if scrollView.contentOffset.y <= groupMemberTableTopOffset {
isSubCanScroll = false
scrollView.contentOffset = CGPoint(x: 0, y: groupMemberTableTopOffset)
}
} else {
scrollView.contentOffset = CGPoint(x: 0, y: groupMemberTableTopOffset)
}
}
}
// MARK: - UIGestureRecognizerDelegate
extension HomeView: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,

View File

@ -79,6 +79,11 @@ class HomeViewController: BaseViewController {
UIDevice.current.isBatteryMonitoringEnabled = true
startLocationTimer()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
PopupWindow.show(popupType: .register)
}
// MARK: - MQTT
private let geocoder = CLGeocoder()

View File

@ -34,7 +34,7 @@ class HomeViewModel {
var groupModel: GroupModel? {
didSet {
guard let model = groupModel else { return }
self.memberList = model.select_group_employee
setMemberList(model.select_group_employee)
}
}
@ -86,16 +86,30 @@ class HomeViewModel {
memberList.first { $0.user_id == id }?.nick_name ?? ""
}
var memberList: [GroupMemberModel] = [] {
didSet {
// -> -> 线
var tempList = memberList
tempList.moveToFirst { $0.is_online == true } // 线
tempList.moveToFirst { $0.user_id == AppContextManager.shared.userId } //
tempList.moveToFirst { isGroupOwn(id: $0.user_id) } //
memberList = tempList
sectionedItems.onNext(memberList.mapSection())
}
private(set) var memberList: [GroupMemberModel] = []
private func setMemberList(_ list: [GroupMemberModel]) {
memberList = sortedMemberList(list)
sectionedItems.onNext(memberList.mapSection())
}
private func sortedMemberList(_ list: [GroupMemberModel]) -> [GroupMemberModel] {
// -> -> 线
list.enumerated()
.sorted { lhs, rhs in
let lhsPriority = memberSortPriority(lhs.element)
let rhsPriority = memberSortPriority(rhs.element)
if lhsPriority != rhsPriority { return lhsPriority < rhsPriority }
return lhs.offset < rhs.offset
}
.map(\.element)
}
private func memberSortPriority(_ member: GroupMemberModel) -> Int {
if isGroupOwn(id: member.user_id) { return 0 }
if member.user_id == AppContextManager.shared.userId { return 1 }
if member.is_online { return 2 }
return 3
}
/// 线 last_active_time "X"
@ -103,7 +117,7 @@ class HomeViewModel {
guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return }
var updated = memberList
updated[idx].is_online = false
memberList = updated
setMemberList(updated)
}
/// MQTT 线
@ -114,7 +128,7 @@ class HomeViewModel {
updated[idx].battery = battery
updated[idx].is_online = isOnline
updated[idx].last_active_time = lastUpdateTime
memberList = updated // didSet + sectionedItems
setMemberList(updated)
}
///
@ -127,7 +141,7 @@ class HomeViewModel {
guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return }
var updated = memberList
updated.remove(at: idx)
memberList = updated
setMemberList(updated)
}
// MARK: - Init

View File

@ -45,22 +45,20 @@ class InteractionView: UIView {
/// normal / fun
private let emojiRelay = BehaviorRelay<[String]>(value: UIView.emojiFileNames)
private var emojiPlaybackGeneration = 0
private var isShowingFunEmoji = false
private func setupRx() {
//
emojiNormalBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
self?.normalBg.isHidden = true
self?.interestBg.isHidden = false
self?.emojiRelay.accept(UIView.emojiFileNames)
self?.switchEmojiCategory(isFun: false)
})
.disposed(by: disposeBag)
emojiFunBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
self?.normalBg.isHidden = false
self?.interestBg.isHidden = true
self?.emojiRelay.accept(UIView.funEmojiFileNames)
self?.switchEmojiCategory(isFun: true)
})
.disposed(by: disposeBag)
@ -100,6 +98,7 @@ class InteractionView: UIView {
emojiRelay
.subscribe(onNext: { [weak self] items in
guard let self = self else { return }
self.stopVisibleEmojiAnimations()
let pages = (items.count + InteractionView.emojiPerPage - 1) / InteractionView.emojiPerPage
self.emojiPageControl.numberOfPages = max(pages, 1)
self.emojiPageControl.currentPage = 0
@ -107,17 +106,30 @@ class InteractionView: UIView {
})
.disposed(by: disposeBag)
}
private func switchEmojiCategory(isFun: Bool) {
guard isShowingFunEmoji != isFun else { return }
isShowingFunEmoji = isFun
stopVisibleEmojiAnimations()
normalBg.isHidden = !isFun
interestBg.isHidden = isFun
emojiRelay.accept(isFun ? UIView.funEmojiFileNames : UIView.emojiFileNames)
}
private lazy var dataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
if let path = Bundle.main.path(forResource: name, ofType: "json") {
cell.configure(path: path)
cell.playAnimation()
}
cell.configure(name: name)
return cell
}
}()
private func stopVisibleEmojiAnimations() {
emojiPlaybackGeneration += 1
emojiCollectionView.visibleCells.forEach { cell in
(cell as? EmojiPanelCell)?.stopAnimation()
}
}
private func setupUI() {
addSubview(infoView)
@ -475,13 +487,6 @@ class InteractionView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
// fun
for name in UIView.funEmojiFileNames {
guard EmojiPanelCell.animationCache[name] == nil,
let path = Bundle.main.path(forResource: name, ofType: "json"),
let anim = LottieAnimation.filepath(path) else { continue }
EmojiPanelCell.animationCache[name] = anim
}
setupUI()
setupRx()
}
@ -504,17 +509,54 @@ extension InteractionView: UICollectionViewDelegate {
let page = Int(scrollView.contentOffset.x / scrollView.bounds.width)
emojiPageControl.currentPage = page
}
func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard collectionView == emojiCollectionView else { return }
(cell as? EmojiPanelCell)?.stopAnimation()
}
}
// MARK: - EmojiPanelCell
final class EmojiPanelCell: UICollectionViewCell {
static var animationCache: [String: LottieAnimation] = [:]
private static var animationCache: [String: LottieAnimation] = [:]
private static var loadingCompletions: [String: [(LottieAnimation?) -> Void]] = [:]
private static let animationQueue = DispatchQueue(label: "com.quicklocation.emoji.animation", qos: .userInitiated)
static func loadAnimation(for name: String, completion: @escaping (LottieAnimation?) -> Void) {
animationQueue.async {
if let cached = animationCache[name] {
DispatchQueue.main.async { completion(cached) }
return
}
if loadingCompletions[name] != nil {
loadingCompletions[name]?.append(completion)
return
}
loadingCompletions[name] = [completion]
let animation: LottieAnimation?
if let path = Bundle.main.path(forResource: name, ofType: "json") {
animation = LottieAnimation.filepath(path)
} else {
animation = nil
}
animationCache[name] = animation
let completions = loadingCompletions.removeValue(forKey: name) ?? []
DispatchQueue.main.async {
completions.forEach { $0(animation) }
}
}
}
private var animationName: String?
private var shouldPlayAnimation = false
private let lottieView: LottieAnimationView = {
let v = LottieAnimationView()
v.contentMode = .scaleAspectFit
// v.loopMode = .loop
v.loopMode = .loop
return v
}()
@ -547,28 +589,38 @@ final class EmojiPanelCell: UICollectionViewCell {
fatalError("init(coder:) has not been implemented")
}
func configure(path: String) {
func configure(name: String) {
animationName = name
shouldPlayAnimation = false
lottieView.stop()
if let cached = Self.animationCache[path] {
lottieView.animation = cached
} else {
lottieView.animation = LottieAnimation.filepath(path)
}
lottieView.animation = nil
lottieView.currentProgress = 0
Self.loadAnimation(for: name) { [weak self] animation in
guard let self = self, self.animationName == name else { return }
self.lottieView.animation = animation
self.lottieView.currentProgress = 0
if self.shouldPlayAnimation {
self.lottieView.play()
}
}
lockView.isHidden = AppContextManager.shared.vip > 1
}
func playAnimation() {
shouldPlayAnimation = true
lottieView.play()
}
func stopAnimation() {
shouldPlayAnimation = false
lottieView.stop()
}
override func prepareForReuse() {
super.prepareForReuse()
animationName = nil
shouldPlayAnimation = false
lottieView.stop()
lottieView.animation = nil
}

View File

@ -29,8 +29,6 @@ class LaunchViewController: BaseViewController {
view.backgroundColor = UIColor(hexStr: "#E0F2FF")
setupLayout()
GroupIMService.shared.initSDK()
experienceBtn.rx.tap.subscribe(onNext: { _ in
// Defaults[\.guideShowVersion] = self.showVersion
AppDelegate.shared.showMainViewController()

View File

@ -33,6 +33,7 @@ final class MineView: UIView {
guard model.vip > 1 else {
vipTitleLab.text = "开通会员"
vipContentLab.text = "会员尊享10+VIP特权"
vipActivateButton.isHidden = false
vipActivateButton.setTitle("立即开通", for: .normal)
return
}
@ -40,6 +41,7 @@ final class MineView: UIView {
vipContentLab.text = model.vip == 3 ? "会员终身有效"
: "会员到期:\(self.getDateInterval2String(date: model.vip_expire_time, dateFormat: "yyyy.MM.dd"))"
vipActivateButton.setTitle("点击续费", for: .normal)
vipActivateButton.isHidden = model.vip == 3
}
private func setupRx() {
@ -142,6 +144,7 @@ final class MineView: UIView {
btn.setTitleColor(UIColor(hexStr: "#00343B"), for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .bold)
btn.cornerRadius = 14
btn.isHidden = true
return btn
}()

Some files were not shown because too many files have changed in this diff Show More