|
|
@ -194,6 +194,7 @@
|
||||||
30A87A6D2FEF5BA10095E7C6 /* SearchLocationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A6C2FEF5BA10095E7C6 /* SearchLocationVC.swift */; };
|
30A87A6D2FEF5BA10095E7C6 /* SearchLocationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A6C2FEF5BA10095E7C6 /* SearchLocationVC.swift */; };
|
||||||
30A87A6F2FEF7BE40095E7C6 /* SearchLocationResultVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A6E2FEF7BE40095E7C6 /* SearchLocationResultVC.swift */; };
|
30A87A6F2FEF7BE40095E7C6 /* SearchLocationResultVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A6E2FEF7BE40095E7C6 /* SearchLocationResultVC.swift */; };
|
||||||
30A87A712FEF7BED0095E7C6 /* SearchLocationResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A87A702FEF7BED0095E7C6 /* SearchLocationResultView.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 */; };
|
30B74B3A2FF2115A00F6744D /* GroupScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B392FF2115A00F6744D /* GroupScheduleView.swift */; };
|
||||||
30B74B3C2FF2117900F6744D /* GroupScheduleVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B3B2FF2117900F6744D /* GroupScheduleVC.swift */; };
|
30B74B3C2FF2117900F6744D /* GroupScheduleVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B3B2FF2117900F6744D /* GroupScheduleVC.swift */; };
|
||||||
30B74B412FF2437E00F6744D /* GroupMemberListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B402FF2437E00F6744D /* GroupMemberListVC.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
30B74B402FF2437E00F6744D /* GroupMemberListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberListVC.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -1062,6 +1064,7 @@
|
||||||
305A762C2FCA8C7000227D26 /* Launch */,
|
305A762C2FCA8C7000227D26 /* Launch */,
|
||||||
3062E8B32FCE6BA400CEF511 /* Scan */,
|
3062E8B32FCE6BA400CEF511 /* Scan */,
|
||||||
30DC18592FD11E7A0041DCD1 /* Web */,
|
30DC18592FD11E7A0041DCD1 /* Web */,
|
||||||
|
30ACD54A2FF6332200174861 /* PopupWindow */,
|
||||||
30EFF3AD2FD7FF1400EB35D4 /* TextInput */,
|
30EFF3AD2FD7FF1400EB35D4 /* TextInput */,
|
||||||
);
|
);
|
||||||
path = Section;
|
path = Section;
|
||||||
|
|
@ -1338,6 +1341,14 @@
|
||||||
path = SearchLocation;
|
path = SearchLocation;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
30ACD54A2FF6332200174861 /* PopupWindow */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
30ACD54B2FF6336000174861 /* PopupWindow.swift */,
|
||||||
|
);
|
||||||
|
path = PopupWindow;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
30B74B382FF2105C00F6744D /* GroupSchedule */ = {
|
30B74B382FF2105C00F6744D /* GroupSchedule */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -1898,6 +1909,7 @@
|
||||||
305A76C72FCA8C7000227D26 /* UserConfigModel.swift in Sources */,
|
305A76C72FCA8C7000227D26 /* UserConfigModel.swift in Sources */,
|
||||||
30B74B472FF3608600F6744D /* ScheduleRecordModel.swift in Sources */,
|
30B74B472FF3608600F6744D /* ScheduleRecordModel.swift in Sources */,
|
||||||
305A76C82FCA8C7000227D26 /* UserConfigResponse.swift in Sources */,
|
305A76C82FCA8C7000227D26 /* UserConfigResponse.swift in Sources */,
|
||||||
|
30ACD54C2FF6336000174861 /* PopupWindow.swift in Sources */,
|
||||||
305A76C92FCA8C7000227D26 /* ApiManager.swift in Sources */,
|
305A76C92FCA8C7000227D26 /* ApiManager.swift in Sources */,
|
||||||
305A76CA2FCA8C7000227D26 /* AppSettings.swift in Sources */,
|
305A76CA2FCA8C7000227D26 /* AppSettings.swift in Sources */,
|
||||||
305A76CB2FCA8C7000227D26 /* Authorize.swift in Sources */,
|
305A76CB2FCA8C7000227D26 /* Authorize.swift in Sources */,
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,4 @@
|
||||||
uuid = "AE547311-A826-480F-B1ED-BD6B815729FA"
|
uuid = "AE547311-A826-480F-B1ED-BD6B815729FA"
|
||||||
type = "0"
|
type = "0"
|
||||||
version = "2.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>
|
</Bucket>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"provides-namespace" : true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/ItineraryTrace/dashed.imageset/Slice 3@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 340 B |
BIN
QuickLocation/Assets.xcassets/ItineraryTrace/dashed.imageset/Slice 3@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 348 B |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
22
QuickLocation/Assets.xcassets/ItineraryTrace/end_small.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/ItineraryTrace/end_small.imageset/Group_2159@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1011 B |
BIN
QuickLocation/Assets.xcassets/ItineraryTrace/end_small.imageset/Group_2159@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/ItineraryTrace/event.imageset/Group_326@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
QuickLocation/Assets.xcassets/ItineraryTrace/event.imageset/Group_326@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 303 B |
|
After Width: | Height: | Size: 413 B |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/ItineraryTrace/play.imageset/Group_2174@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 988 B |
BIN
QuickLocation/Assets.xcassets/ItineraryTrace/play.imageset/Group_2174@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"provides-namespace" : true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
22
QuickLocation/Assets.xcassets/PopupWindow/register2_logo.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/register2_logo.imageset/Group 2127@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/register2_logo.imageset/Group 2127@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 187 KiB |
22
QuickLocation/Assets.xcassets/PopupWindow/register_bg.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/register_bg.imageset/Group 2188@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/register_bg.imageset/Group 2188@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 236 KiB |
22
QuickLocation/Assets.xcassets/PopupWindow/register_heart.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/register_heart.imageset/Group_2323@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/register_heart.imageset/Group_2323@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 50 KiB |
22
QuickLocation/Assets.xcassets/PopupWindow/register_logo.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/register_logo.imageset/Group_2331@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/register_logo.imageset/Group_2331@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 183 KiB |
22
QuickLocation/Assets.xcassets/PopupWindow/register_text.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/register_text.imageset/register_text@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/register_text.imageset/register_text@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/search_bg.imageset/search_bg@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/search_bg.imageset/search_bg@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 260 KiB |
22
QuickLocation/Assets.xcassets/PopupWindow/search_earth.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/search_earth.imageset/组 3 2@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/search_earth.imageset/组 3 2@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 88 KiB |
22
QuickLocation/Assets.xcassets/PopupWindow/search_people.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/search_people.imageset/image 111@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/search_people.imageset/image 111@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 83 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/star_1.imageset/Group_2322@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/star_1.imageset/Group_2322@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip1_heart.imageset/vip1_unlock@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip1_heart.imageset/vip1_unlock@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 130 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip1_logo.imageset/vip1_logo@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip1_logo.imageset/vip1_logo@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 25 KiB |
22
QuickLocation/Assets.xcassets/PopupWindow/vip1_tips_1.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip1_tips_1.imageset/Group 2086@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip1_tips_1.imageset/Group 2086@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
22
QuickLocation/Assets.xcassets/PopupWindow/vip1_tips_2.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip1_tips_2.imageset/Group 2087@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip1_tips_2.imageset/Group 2087@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
22
QuickLocation/Assets.xcassets/PopupWindow/vip1_tips_3.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip1_tips_3.imageset/Group 2088@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip1_tips_3.imageset/Group 2088@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip_bg.imageset/Group 2209@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
QuickLocation/Assets.xcassets/PopupWindow/vip_bg.imageset/Group 2209@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 202 KiB |
|
|
@ -73,6 +73,8 @@ enum Route: String {
|
||||||
case groupSchedule = "groupSchedule"
|
case groupSchedule = "groupSchedule"
|
||||||
/// 圈子成员列表
|
/// 圈子成员列表
|
||||||
case groupMemberList = "groupMemberList"
|
case groupMemberList = "groupMemberList"
|
||||||
|
/// 行程轨迹
|
||||||
|
case itineraryTrace = "itineraryTrace"
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Route: RouterTarget {
|
extension Route: RouterTarget {
|
||||||
|
|
@ -338,6 +340,12 @@ extension AppRouter: AppRouterProtocol {
|
||||||
AppRouter.register(Route.groupMemberList) { url, parameters in
|
AppRouter.register(Route.groupMemberList) { url, parameters in
|
||||||
GroupMemberListVC(groupKey: parameters["groupKey"].safeString)
|
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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,9 @@ final class GroupChatVC: BaseViewController {
|
||||||
|
|
||||||
// MARK: - Bindings
|
// MARK: - Bindings
|
||||||
private func bindViewModel() {
|
private func bindViewModel() {
|
||||||
|
rootView.tableView.rx.setDelegate(self)
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
viewModel.output.messages
|
viewModel.output.messages
|
||||||
.skip(1)
|
.skip(1)
|
||||||
.map { [ChatSectionModel(model: "", items: $0)] }
|
.map { [ChatSectionModel(model: "", items: $0)] }
|
||||||
|
|
@ -158,10 +161,7 @@ final class GroupChatVC: BaseViewController {
|
||||||
private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
|
private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
|
||||||
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
|
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
|
||||||
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
|
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
|
||||||
if let path = Bundle.main.path(forResource: name, ofType: "json") {
|
cell.configure(name: name)
|
||||||
cell.configure(path: path)
|
|
||||||
cell.playAnimation()
|
|
||||||
}
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -300,6 +300,9 @@ final class GroupChatVC: BaseViewController {
|
||||||
// MARK: - 显示表情面板
|
// MARK: - 显示表情面板
|
||||||
private func showEmojiPanel() {
|
private func showEmojiPanel() {
|
||||||
let show = self.rootView.emojiPanelView.isHidden
|
let show = self.rootView.emojiPanelView.isHidden
|
||||||
|
if !show {
|
||||||
|
self.rootView.stopVisibleEmojiAnimations()
|
||||||
|
}
|
||||||
self.rootView.emojiPanelView.isHidden = !show
|
self.rootView.emojiPanelView.isHidden = !show
|
||||||
let offset: CGFloat = show ? 220 : 0
|
let offset: CGFloat = show ? 220 : 0
|
||||||
UIView.animate(withDuration: 0.25) {
|
UIView.animate(withDuration: 0.25) {
|
||||||
|
|
@ -313,6 +316,10 @@ final class GroupChatVC: BaseViewController {
|
||||||
|
|
||||||
if show {
|
if show {
|
||||||
self.rootView.textField.resignFirstResponder()
|
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
|
// MARK: - MessageListenerProxy
|
||||||
private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener {
|
private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener {
|
||||||
private let handler: (OIMMessageInfo) -> Void
|
private let handler: (OIMMessageInfo) -> Void
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ class GroupChatView: UIView {
|
||||||
var bottomBarBottomConstraint: NSLayoutConstraint?
|
var bottomBarBottomConstraint: NSLayoutConstraint?
|
||||||
/// 语音录制回调
|
/// 语音录制回调
|
||||||
var onVoiceRecordState: ((VoiceRecordState) -> Void)?
|
var onVoiceRecordState: ((VoiceRecordState) -> Void)?
|
||||||
|
private var emojiPlaybackGeneration = 0
|
||||||
|
|
||||||
private func setupRx() {
|
private func setupRx() {
|
||||||
|
|
||||||
|
|
@ -56,6 +57,7 @@ class GroupChatView: UIView {
|
||||||
|| textField.isFirstResponder
|
|| textField.isFirstResponder
|
||||||
|
|
||||||
guard needsReset else { return }
|
guard needsReset else { return }
|
||||||
|
stopVisibleEmojiAnimations()
|
||||||
emojiPanelView.isHidden = true
|
emojiPanelView.isHidden = true
|
||||||
voiceRecordView.isHidden = true
|
voiceRecordView.isHidden = true
|
||||||
if !excludeTextField { textField.resignFirstResponder() }
|
if !excludeTextField { textField.resignFirstResponder() }
|
||||||
|
|
@ -64,6 +66,44 @@ class GroupChatView: UIView {
|
||||||
self.voiceRecordView.layoutChain.bottom(-252)
|
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
|
// MARK: - Setup
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
|
|
@ -270,6 +310,7 @@ class GroupChatView: UIView {
|
||||||
lazy var reviewBtn: UIButton = {
|
lazy var reviewBtn: UIButton = {
|
||||||
let btn = UIButton(type: .custom)
|
let btn = UIButton(type: .custom)
|
||||||
btn.setImage(UIImage(named: "IM/review"), for: .normal)
|
btn.setImage(UIImage(named: "IM/review"), for: .normal)
|
||||||
|
btn.isHidden = true
|
||||||
return btn
|
return btn
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -809,6 +850,9 @@ final class EmojiSendMsgCell: UITableViewCell {
|
||||||
return v
|
return v
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private var animationName: String?
|
||||||
|
private var shouldPlayAnimation = false
|
||||||
|
|
||||||
override init(style: CellStyle, reuseIdentifier: String?) {
|
override init(style: CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
|
|
@ -840,11 +884,43 @@ final class EmojiSendMsgCell: UITableViewCell {
|
||||||
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
|
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
|
||||||
avatarView.image = msg.avatar
|
avatarView.image = msg.avatar
|
||||||
let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0
|
let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0
|
||||||
if index < Self.emojiFileNames.count,
|
guard Self.emojiFileNames.indices.contains(index) else {
|
||||||
let path = Bundle.main.path(forResource: Self.emojiFileNames[index], ofType: "json") {
|
animationName = nil
|
||||||
lottieView.animation = LottieAnimation.filepath(path)
|
lottieView.animation = nil
|
||||||
lottieView.play()
|
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 {
|
private func formatTime(_ t: TimeInterval) -> String {
|
||||||
|
|
@ -896,6 +972,9 @@ final class EmojiReceivedMsgCell: UITableViewCell {
|
||||||
return v
|
return v
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private var animationName: String?
|
||||||
|
private var shouldPlayAnimation = false
|
||||||
|
|
||||||
override init(style: CellStyle, reuseIdentifier: String?) {
|
override init(style: CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
|
|
@ -933,11 +1012,43 @@ final class EmojiReceivedMsgCell: UITableViewCell {
|
||||||
avatarView.image = msg.avatar
|
avatarView.image = msg.avatar
|
||||||
nameLabel.text = msg.senderName
|
nameLabel.text = msg.senderName
|
||||||
let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0
|
let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0
|
||||||
if index < Self.emojiFileNames.count,
|
guard Self.emojiFileNames.indices.contains(index) else {
|
||||||
let path = Bundle.main.path(forResource: Self.emojiFileNames[index], ofType: "json") {
|
animationName = nil
|
||||||
lottieView.animation = LottieAnimation.filepath(path)
|
lottieView.animation = nil
|
||||||
lottieView.play()
|
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 {
|
private func formatTime(_ t: TimeInterval) -> String {
|
||||||
|
|
@ -955,10 +1066,34 @@ final class EmojiReceivedMsgCell: UITableViewCell {
|
||||||
// MARK: - UICollectionViewDelegate (page control)
|
// MARK: - UICollectionViewDelegate (page control)
|
||||||
extension GroupChatView: UICollectionViewDelegate {
|
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) {
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
guard scrollView == emojiCollectionView else { return }
|
guard scrollView == emojiCollectionView else { return }
|
||||||
let page = Int(scrollView.contentOffset.x / scrollView.bounds.width)
|
let page = Int(scrollView.contentOffset.x / scrollView.bounds.width)
|
||||||
emojiPageControl.currentPage = page
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ struct DrivingStatsResponse: BaseModelProtocol {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DrivingStatsModel: Mappable {
|
struct DrivingStatsModel: Mappable, Equatable {
|
||||||
var uuid: String = UUID().uuidString
|
var uuid: String = UUID().uuidString
|
||||||
/// 行驶总距离
|
/// 行驶总距离
|
||||||
var distance_km: Double = 0
|
var distance_km: Double = 0
|
||||||
|
|
@ -67,3 +67,11 @@ struct DrivingStatsModel: Mappable {
|
||||||
total <- map["total"]
|
total <- map["total"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension DrivingStatsModel: IdentifiableType {
|
||||||
|
public typealias Identity = String
|
||||||
|
|
||||||
|
public var identity: String {
|
||||||
|
return uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ class GroupMemberListVC: BaseViewController {
|
||||||
self?.rootView.updateTableViewHeight(count: items.first?.items.count ?? 0)
|
self?.rootView.updateTableViewHeight(count: items.first?.items.count ?? 0)
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
|
rootView.tableView.rx.modelSelected(ScheduleRecordModel.self)
|
||||||
|
.subscribe(viewModel.scheduleCellRecordAction.inputs)
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
// 日期选择 → 滚动报告到顶部 + 请求驾驶数据
|
// 日期选择 → 滚动报告到顶部 + 请求驾驶数据
|
||||||
rootView.selectedDate
|
rootView.selectedDate
|
||||||
.skip(1)
|
.skip(1)
|
||||||
|
|
@ -84,12 +88,13 @@ class GroupMemberListVC: BaseViewController {
|
||||||
guard !viewModel.memberId.isEmpty else { return }
|
guard !viewModel.memberId.isEmpty else { return }
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
let start = calendar.startOfDay(for: date)
|
let start = calendar.startOfDay(for: date)
|
||||||
let end: Date
|
let end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: date) ?? date
|
||||||
if calendar.isDateInToday(date) {
|
// let end: Date
|
||||||
end = Date() // 当天以当前时间为结束
|
// if calendar.isDateInToday(date) {
|
||||||
} else {
|
// end = Date() // 当天以当前时间为结束
|
||||||
end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: date) ?? date
|
// } else {
|
||||||
}
|
// end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: date) ?? date
|
||||||
|
// }
|
||||||
requestDrivingEvents(start_time: "\(Int64(start.timeIntervalSince1970 * 1000))",
|
requestDrivingEvents(start_time: "\(Int64(start.timeIntervalSince1970 * 1000))",
|
||||||
end_time: "\(Int64(end.timeIntervalSince1970 * 1000))")
|
end_time: "\(Int64(end.timeIntervalSince1970 * 1000))")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,15 @@ class GroupMemberListVM {
|
||||||
DrivingEventItem(title: "频繁变道", iconName: "GroupMemberList/6"),
|
DrivingEventItem(title: "频繁变道", iconName: "GroupMemberList/6"),
|
||||||
DrivingEventItem(title: "长时间驾驶", iconName: "GroupMemberList/8")
|
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 {
|
func isCurrentUser(id: String) -> Bool {
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,759 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import RxSwift
|
||||||
|
import RxCocoa
|
||||||
|
import ObjectMapper
|
||||||
|
import SwiftyUserDefaults
|
||||||
|
import AMapNaviKit
|
||||||
|
import AMapSearchKit
|
||||||
|
|
||||||
class ItineraryTraceVC: BaseViewController {
|
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() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
rootView.eventCollectionView.dataSource = self
|
||||||
// Do any additional setup after loading the view.
|
rootView.eventCollectionView.delegate = self
|
||||||
|
setupMap()
|
||||||
|
populateData()
|
||||||
|
addAnnotations()
|
||||||
|
drawRoute()
|
||||||
|
setupPlaybackControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
// MARK: - Navigation
|
super.viewDidDisappear(animated)
|
||||||
|
if isMovingFromParent || isBeingDismissed {
|
||||||
// In a storyboard-based application, you will often want to do a little preparation before navigation
|
stopPlayback(resetProgress: false)
|
||||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
rootView.cleanupMap()
|
||||||
// Get the new view controller using segue.destination.
|
}
|
||||||
// Pass the selected object to the new view controller.
|
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,11 @@ class ItineraryTraceView: UIView {
|
||||||
navBarView.addSubview(backBtn)
|
navBarView.addSubview(backBtn)
|
||||||
navBarView.addSubview(navTitleLabel)
|
navBarView.addSubview(navTitleLabel)
|
||||||
|
|
||||||
|
addSubview(playView)
|
||||||
|
addSubview(bottomView)
|
||||||
|
|
||||||
mapView.layoutChain
|
mapView.layoutChain
|
||||||
.edges()
|
.edges(excludingEdge: .bottom)
|
||||||
|
|
||||||
navBgView.layoutChain
|
navBgView.layoutChain
|
||||||
.edges(excludingEdge: .bottom)
|
.edges(excludingEdge: .bottom)
|
||||||
|
|
@ -48,7 +51,17 @@ class ItineraryTraceView: UIView {
|
||||||
.top(kStatusBarHeight + 12)
|
.top(kStatusBarHeight + 12)
|
||||||
.centerX()
|
.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
|
// MARK: - Views
|
||||||
|
|
@ -129,14 +142,104 @@ class ItineraryTraceView: UIView {
|
||||||
.centerY(startTimeLab)
|
.centerY(startTimeLab)
|
||||||
.leftToRightOfView(dotView, offset: 12)
|
.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(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
|
endTimeLab.layoutChain
|
||||||
.topToBottomOfView(startTimeLab, offset: 24)
|
.topToBottomOfView(startTimeLab, offset: 24)
|
||||||
.leftToView(startTimeLab)
|
.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
|
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 的行程详细信息
|
/// xxx 的行程详细信息
|
||||||
lazy var titleLab: UILabel = {
|
lazy var titleLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
|
|
@ -147,6 +250,7 @@ class ItineraryTraceView: UIView {
|
||||||
|
|
||||||
lazy var startTimeLab: UILabel = {
|
lazy var startTimeLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
|
label.text = " "
|
||||||
label.textColor = UIColor(hexStr: "#666666")
|
label.textColor = UIColor(hexStr: "#666666")
|
||||||
label.font = .systemFont(ofSize: 16, weight: .medium)
|
label.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
return label
|
return label
|
||||||
|
|
@ -154,6 +258,7 @@ class ItineraryTraceView: UIView {
|
||||||
|
|
||||||
lazy var startAddressLab: UILabel = {
|
lazy var startAddressLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
|
label.text = " "
|
||||||
label.textColor = UIColor(hexStr: "#999999")
|
label.textColor = UIColor(hexStr: "#999999")
|
||||||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
return label
|
return label
|
||||||
|
|
@ -161,6 +266,7 @@ class ItineraryTraceView: UIView {
|
||||||
|
|
||||||
lazy var endTimeLab: UILabel = {
|
lazy var endTimeLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
|
label.text = " "
|
||||||
label.textColor = UIColor(hexStr: "#666666")
|
label.textColor = UIColor(hexStr: "#666666")
|
||||||
label.font = .systemFont(ofSize: 16, weight: .medium)
|
label.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
return label
|
return label
|
||||||
|
|
@ -168,11 +274,36 @@ class ItineraryTraceView: UIView {
|
||||||
|
|
||||||
lazy var endAddressLab: UILabel = {
|
lazy var endAddressLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
|
label.text = " "
|
||||||
label.textColor = UIColor(hexStr: "#999999")
|
label.textColor = UIColor(hexStr: "#999999")
|
||||||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
return label
|
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) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
|
|
@ -184,3 +315,62 @@ class ItineraryTraceView: UIView {
|
||||||
fatalError("init(coder:) has not been implemented")
|
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") }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,11 @@ struct ScheduleRecordModel: Mappable, Equatable {
|
||||||
/// 结束时间
|
/// 结束时间
|
||||||
var end_time: String = ""
|
var end_time: String = ""
|
||||||
/// 公里数
|
/// 公里数
|
||||||
var distance_km: String = ""
|
var distance_km: Double = 0
|
||||||
/// 驾驶时间
|
/// 驾驶时间
|
||||||
var duration_minutes: Int = 0
|
var duration_minutes: Int = 0
|
||||||
/// 平均时速
|
/// 平均时速
|
||||||
var avg_speed: String = ""
|
var avg_speed: Double = 0
|
||||||
/// 最高时速
|
/// 最高时速
|
||||||
var max_speed: Double = 0
|
var max_speed: Double = 0
|
||||||
/// 评分
|
/// 评分
|
||||||
|
|
@ -58,7 +58,7 @@ struct ScheduleRecordModel: Mappable, Equatable {
|
||||||
var trajectory_path: [TrackPoint] = []
|
var trajectory_path: [TrackPoint] = []
|
||||||
var driving_events: [TrackEvent] = []
|
var driving_events: [TrackEvent] = []
|
||||||
var stay_points: [StayPoint] = []
|
var stay_points: [StayPoint] = []
|
||||||
var statistics: TripStatistics?
|
var statistics: DrivingStatsModel?
|
||||||
|
|
||||||
init?(map: Map) {}
|
init?(map: Map) {}
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ extension ScheduleRecordModel: IdentifiableType {
|
||||||
public typealias Identity = String
|
public typealias Identity = String
|
||||||
|
|
||||||
public var identity: String {
|
public var identity: String {
|
||||||
return id
|
return uuid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,8 +100,8 @@ struct TripLocation: Mappable, Equatable {
|
||||||
init?(map: Map) {}
|
init?(map: Map) {}
|
||||||
|
|
||||||
mutating func mapping(map: Map) {
|
mutating func mapping(map: Map) {
|
||||||
lat <- map["lat"]
|
lat <- map["latitude"]
|
||||||
lng <- map["lng"]
|
lng <- map["longitude"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,8 +153,8 @@ struct TrackPoint: Mappable, Equatable {
|
||||||
init?(map: Map) {}
|
init?(map: Map) {}
|
||||||
|
|
||||||
mutating func mapping(map: Map) {
|
mutating func mapping(map: Map) {
|
||||||
lat <- map["lat"]
|
lat <- map["location.latitude"]
|
||||||
lng <- map["lng"]
|
lng <- map["location.longitude"]
|
||||||
timestamp <- map["timestamp"]
|
timestamp <- map["timestamp"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -170,16 +170,23 @@ extension TrackPoint: IdentifiableType {
|
||||||
struct TrackEvent: Mappable, Equatable {
|
struct TrackEvent: Mappable, Equatable {
|
||||||
var uuid: String = UUID().uuidString
|
var uuid: String = UUID().uuidString
|
||||||
|
|
||||||
var type: String = ""
|
var event_type: String = ""
|
||||||
var timestamp: Int64 = 0
|
var timestamp: Int64 = 0
|
||||||
var value: Int = 0
|
var value: Int = 0
|
||||||
|
var latitude: Double = 0
|
||||||
|
var longitude: Double = 0
|
||||||
|
|
||||||
|
var address: TripAddress?
|
||||||
|
|
||||||
init?(map: Map) {}
|
init?(map: Map) {}
|
||||||
|
|
||||||
mutating func mapping(map: Map) {
|
mutating func mapping(map: Map) {
|
||||||
type <- map["type"]
|
event_type <- map["event_type"]
|
||||||
timestamp <- map["timestamp"]
|
timestamp <- map["timestamp"]
|
||||||
value <- map["value"]
|
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) {}
|
init?(map: Map) {}
|
||||||
|
|
||||||
mutating func mapping(map: Map) {
|
mutating func mapping(map: Map) {
|
||||||
lat <- map["lat"]
|
lat <- map["latitude"]
|
||||||
lng <- map["lng"]
|
lng <- map["longitude"]
|
||||||
start_time <- map["start_time"]
|
start_time <- map["start_time"]
|
||||||
end_time <- map["end_time"]
|
end_time <- map["end_time"]
|
||||||
address <- map["address"]
|
address <- map["address"]
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class GroupListPopView: UIView {
|
||||||
defaultGroupKey = model.default_group_key
|
defaultGroupKey = model.default_group_key
|
||||||
groupList = model.groups
|
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)
|
tableView.layoutChain.height(viewHeight, relation: .greaterThanOrEqual)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +101,6 @@ class GroupListPopView: UIView {
|
||||||
tableView.estimatedRowHeight = 68
|
tableView.estimatedRowHeight = 68
|
||||||
tableView.showsVerticalScrollIndicator = false
|
tableView.showsVerticalScrollIndicator = false
|
||||||
tableView.bounces = false
|
tableView.bounces = false
|
||||||
tableView.isScrollEnabled = false
|
|
||||||
tableView.register(GroupListCell.self)
|
tableView.register(GroupListCell.self)
|
||||||
tableView.tableHeaderView = UIView(frame: CGRectMake(0, 0, kScreenWidth, 10))
|
tableView.tableHeaderView = UIView(frame: CGRectMake(0, 0, kScreenWidth, 10))
|
||||||
tableView.dataSource = self
|
tableView.dataSource = self
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ class GroupMemberCell: UITableViewCell {
|
||||||
nameLab.textColor = UIColor(hexStr: isCurrentUser ? "#16B3FF" : "#0F2846")
|
nameLab.textColor = UIColor(hexStr: isCurrentUser ? "#16B3FF" : "#0F2846")
|
||||||
|
|
||||||
// 会员权益
|
// 会员权益
|
||||||
|
batteryInfoView.isHidden = true
|
||||||
if AppContextManager.shared.vip > 1, model.is_online {
|
if AppContextManager.shared.vip > 1, model.is_online {
|
||||||
batteryInfoView.isHidden = model.battery.int == 0
|
batteryInfoView.isHidden = model.battery.int == 0
|
||||||
// 电量 16是电池图标宽度,右边有电池造型需要减去
|
// 电量 16是电池图标宽度,右边有电池造型需要减去
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ class HomeView: UIView {
|
||||||
/// 内嵌 tableView 是否可滑动(最大化时才可)
|
/// 内嵌 tableView 是否可滑动(最大化时才可)
|
||||||
private var isSubCanScroll = false
|
private var isSubCanScroll = false
|
||||||
|
|
||||||
|
private var groupMemberTableTopOffset: CGFloat {
|
||||||
|
-groupMemberView.tableView.adjustedContentInset.top
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Map
|
// MARK: - Map
|
||||||
#if !targetEnvironment(simulator)
|
#if !targetEnvironment(simulator)
|
||||||
lazy var mapView: MAMapView! = {
|
lazy var mapView: MAMapView! = {
|
||||||
|
|
@ -54,7 +58,7 @@ class HomeView: UIView {
|
||||||
mv.showsScale = false
|
mv.showsScale = false
|
||||||
mv.isRotateEnabled = false
|
mv.isRotateEnabled = false
|
||||||
mv.isRotateCameraEnabled = 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
|
return mv
|
||||||
}()
|
}()
|
||||||
#else
|
#else
|
||||||
|
|
@ -89,14 +93,7 @@ class HomeView: UIView {
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
|
||||||
// 嵌套滑动协调:contentOffset 只复位位置,scroll 状态由 pan handler + panVelocity 管理
|
groupMemberView.tableView.rx.setDelegate(self)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
// 查找位置
|
// 查找位置
|
||||||
|
|
@ -226,6 +223,7 @@ class HomeView: UIView {
|
||||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleGroupMemberPan(_:)))
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleGroupMemberPan(_:)))
|
||||||
pan.delegate = self
|
pan.delegate = self
|
||||||
groupMemberView.addGestureRecognizer(pan)
|
groupMemberView.addGestureRecognizer(pan)
|
||||||
|
groupMemberView.tableView.isScrollEnabled = false
|
||||||
|
|
||||||
// 底部弹出面板(quickMessageView 在上,interactionView 在下)
|
// 底部弹出面板(quickMessageView 在上,interactionView 在下)
|
||||||
addSubview(interactionView)
|
addSubview(interactionView)
|
||||||
|
|
@ -288,21 +286,23 @@ class HomeView: UIView {
|
||||||
|
|
||||||
switch pan.state {
|
switch pan.state {
|
||||||
case .began:
|
case .began:
|
||||||
|
layoutIfNeeded()
|
||||||
panStartTop = groupMemberView.frame.minY
|
panStartTop = groupMemberView.frame.minY
|
||||||
|
|
||||||
case .changed:
|
case .changed:
|
||||||
let newTop = panStartTop + pan.translation(in: self).y
|
let newTop = panStartTop + pan.translation(in: self).y
|
||||||
|
let scrollOffset = groupMemberView.tableView.contentOffset.y
|
||||||
|
|
||||||
// 最大化状态下,tableView 可以向下滑时,不干扰自身滑动;
|
// 最大化状态下,tableView 可以向下滑时,不干扰自身滑动;
|
||||||
// 只有 tableView 到达顶部继续下拉时,才切换为 view 拖拽
|
// 只有 tableView 到达顶部继续下拉时,才切换为 view 拖拽
|
||||||
if isSubCanScroll {
|
if isSubCanScroll {
|
||||||
let tableViewOffset = self.groupMemberView.tableView.contentOffset.y
|
if scrollOffset > groupMemberTableTopOffset + 0.5 {
|
||||||
if tableViewOffset > 0 {
|
|
||||||
// 内容正在滑动,不移动 GroupMemberView
|
// 内容正在滑动,不移动 GroupMemberView
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 内容滑到顶部 → 切回 Pan 拖拽(用户下拉时 velocity > 0)
|
|
||||||
if pan.velocity(in: self).y > 0 || groupMemberView.frame.minY > groupMemberUpLimit + 1 {
|
if pan.velocity(in: self).y > 0 || groupMemberView.frame.minY > groupMemberUpLimit + 1 {
|
||||||
|
// 内容真正到顶并继续下拉 → 切回面板拖拽
|
||||||
isSubCanScroll = false
|
isSubCanScroll = false
|
||||||
panStartTop = groupMemberView.frame.minY
|
panStartTop = groupMemberView.frame.minY
|
||||||
}
|
}
|
||||||
|
|
@ -323,23 +323,28 @@ class HomeView: UIView {
|
||||||
let velocity = pan.velocity(in: self)
|
let velocity = pan.velocity(in: self)
|
||||||
let isNearUp = abs(groupMemberView.frame.minY - groupMemberUpLimit) < abs(groupMemberView.frame.minY - groupMemberDownLimit)
|
let isNearUp = abs(groupMemberView.frame.minY - groupMemberUpLimit) < abs(groupMemberView.frame.minY - groupMemberDownLimit)
|
||||||
let target: CGFloat
|
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
|
target = velocity.y < 0 ? groupMemberUpLimit : groupMemberDownLimit
|
||||||
} else {
|
} else {
|
||||||
target = isNearUp ? groupMemberUpLimit : groupMemberDownLimit
|
target = isNearUp ? groupMemberUpLimit : groupMemberDownLimit
|
||||||
}
|
}
|
||||||
topConstraint.constant = target - groupMemberDownLimit + 10
|
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,
|
UIView.animate(withDuration: 0.25, delay: 0,
|
||||||
options: [.curveEaseOut, .allowUserInteraction]) {
|
options: [.curveEaseOut, .allowUserInteraction]) {
|
||||||
self.layoutIfNeeded()
|
self.layoutIfNeeded()
|
||||||
} completion: { _ in
|
} completion: { _ in
|
||||||
let atTop = target == self.groupMemberUpLimit
|
self.groupMemberView.tableView.isScrollEnabled = atTop
|
||||||
self.isSubCanScroll = atTop
|
|
||||||
// 未最大化时重置 tableView 到顶部
|
|
||||||
if !atTop {
|
|
||||||
self.groupMemberView.tableView.contentOffset.y = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
@ -351,8 +356,9 @@ class HomeView: UIView {
|
||||||
func dismissGroupMemberView() {
|
func dismissGroupMemberView() {
|
||||||
guard isGroupMemberLimitsSet, let topConstraint = groupMemberTopConstraint else { return }
|
guard isGroupMemberLimitsSet, let topConstraint = groupMemberTopConstraint else { return }
|
||||||
isSubCanScroll = false
|
isSubCanScroll = false
|
||||||
|
groupMemberView.tableView.isScrollEnabled = false
|
||||||
topConstraint.constant = groupMemberDownLimit - groupMemberDownLimit + 10
|
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) {
|
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) {
|
||||||
self.layoutIfNeeded()
|
self.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
@ -412,7 +418,8 @@ class HomeView: UIView {
|
||||||
view.backgroundColor = .white
|
view.backgroundColor = .white
|
||||||
view.cornerRadius = 18
|
view.cornerRadius = 18
|
||||||
view.clipsToBounds = false
|
view.clipsToBounds = false
|
||||||
|
view.isHidden = true
|
||||||
|
|
||||||
view.addSubview(messageIcon)
|
view.addSubview(messageIcon)
|
||||||
view.addSubview(messageDotView)
|
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
|
// MARK: - UIGestureRecognizerDelegate
|
||||||
extension HomeView: UIGestureRecognizerDelegate {
|
extension HomeView: UIGestureRecognizerDelegate {
|
||||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,11 @@ class HomeViewController: BaseViewController {
|
||||||
UIDevice.current.isBatteryMonitoringEnabled = true
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||||
startLocationTimer()
|
startLocationTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
PopupWindow.show(popupType: .register)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - MQTT 位置上报
|
// MARK: - MQTT 位置上报
|
||||||
private let geocoder = CLGeocoder()
|
private let geocoder = CLGeocoder()
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class HomeViewModel {
|
||||||
var groupModel: GroupModel? {
|
var groupModel: GroupModel? {
|
||||||
didSet {
|
didSet {
|
||||||
guard let model = groupModel else { return }
|
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 ?? ""
|
memberList.first { $0.user_id == id }?.nick_name ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var memberList: [GroupMemberModel] = [] {
|
private(set) var memberList: [GroupMemberModel] = []
|
||||||
didSet {
|
|
||||||
// 排列顺序 圈主 -> 自己 -> 在线
|
private func setMemberList(_ list: [GroupMemberModel]) {
|
||||||
var tempList = memberList
|
memberList = sortedMemberList(list)
|
||||||
tempList.moveToFirst { $0.is_online == true } // 在线
|
sectionedItems.onNext(memberList.mapSection())
|
||||||
tempList.moveToFirst { $0.user_id == AppContextManager.shared.userId } // 自己
|
}
|
||||||
tempList.moveToFirst { isGroupOwn(id: $0.user_id) } // 圈主
|
|
||||||
memberList = tempList
|
private func sortedMemberList(_ list: [GroupMemberModel]) -> [GroupMemberModel] {
|
||||||
sectionedItems.onNext(memberList.mapSection())
|
// 排列顺序:圈主 -> 自己 -> 在线;同优先级保持接口原始顺序
|
||||||
}
|
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分钟前")
|
/// 标记成员离线(保留 last_active_time 用于显示"X分钟前")
|
||||||
|
|
@ -103,7 +117,7 @@ class HomeViewModel {
|
||||||
guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return }
|
guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return }
|
||||||
var updated = memberList
|
var updated = memberList
|
||||||
updated[idx].is_online = false
|
updated[idx].is_online = false
|
||||||
memberList = updated
|
setMemberList(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MQTT 位置更新后刷新成员的地址、电量、在线状态
|
/// MQTT 位置更新后刷新成员的地址、电量、在线状态
|
||||||
|
|
@ -114,7 +128,7 @@ class HomeViewModel {
|
||||||
updated[idx].battery = battery
|
updated[idx].battery = battery
|
||||||
updated[idx].is_online = isOnline
|
updated[idx].is_online = isOnline
|
||||||
updated[idx].last_active_time = lastUpdateTime
|
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 }
|
guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return }
|
||||||
var updated = memberList
|
var updated = memberList
|
||||||
updated.remove(at: idx)
|
updated.remove(at: idx)
|
||||||
memberList = updated
|
setMemberList(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
|
||||||
|
|
@ -45,22 +45,20 @@ class InteractionView: UIView {
|
||||||
|
|
||||||
/// 当前表情列表(normal / fun)
|
/// 当前表情列表(normal / fun)
|
||||||
private let emojiRelay = BehaviorRelay<[String]>(value: UIView.emojiFileNames)
|
private let emojiRelay = BehaviorRelay<[String]>(value: UIView.emojiFileNames)
|
||||||
|
private var emojiPlaybackGeneration = 0
|
||||||
|
private var isShowingFunEmoji = false
|
||||||
|
|
||||||
private func setupRx() {
|
private func setupRx() {
|
||||||
// 切换表情分类
|
// 切换表情分类
|
||||||
emojiNormalBtn.rx.tap
|
emojiNormalBtn.rx.tap
|
||||||
.subscribe(onNext: { [weak self] _ in
|
.subscribe(onNext: { [weak self] _ in
|
||||||
self?.normalBg.isHidden = true
|
self?.switchEmojiCategory(isFun: false)
|
||||||
self?.interestBg.isHidden = false
|
|
||||||
self?.emojiRelay.accept(UIView.emojiFileNames)
|
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
emojiFunBtn.rx.tap
|
emojiFunBtn.rx.tap
|
||||||
.subscribe(onNext: { [weak self] _ in
|
.subscribe(onNext: { [weak self] _ in
|
||||||
self?.normalBg.isHidden = false
|
self?.switchEmojiCategory(isFun: true)
|
||||||
self?.interestBg.isHidden = true
|
|
||||||
self?.emojiRelay.accept(UIView.funEmojiFileNames)
|
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
|
@ -100,6 +98,7 @@ class InteractionView: UIView {
|
||||||
emojiRelay
|
emojiRelay
|
||||||
.subscribe(onNext: { [weak self] items in
|
.subscribe(onNext: { [weak self] items in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
self.stopVisibleEmojiAnimations()
|
||||||
let pages = (items.count + InteractionView.emojiPerPage - 1) / InteractionView.emojiPerPage
|
let pages = (items.count + InteractionView.emojiPerPage - 1) / InteractionView.emojiPerPage
|
||||||
self.emojiPageControl.numberOfPages = max(pages, 1)
|
self.emojiPageControl.numberOfPages = max(pages, 1)
|
||||||
self.emojiPageControl.currentPage = 0
|
self.emojiPageControl.currentPage = 0
|
||||||
|
|
@ -107,17 +106,30 @@ class InteractionView: UIView {
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.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>> = {
|
private lazy var dataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
|
||||||
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
|
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
|
||||||
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
|
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
|
||||||
if let path = Bundle.main.path(forResource: name, ofType: "json") {
|
cell.configure(name: name)
|
||||||
cell.configure(path: path)
|
|
||||||
cell.playAnimation()
|
|
||||||
}
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private func stopVisibleEmojiAnimations() {
|
||||||
|
emojiPlaybackGeneration += 1
|
||||||
|
emojiCollectionView.visibleCells.forEach { cell in
|
||||||
|
(cell as? EmojiPanelCell)?.stopAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
addSubview(infoView)
|
addSubview(infoView)
|
||||||
|
|
@ -475,13 +487,6 @@ class InteractionView: UIView {
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
backgroundColor = .clear
|
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()
|
setupUI()
|
||||||
setupRx()
|
setupRx()
|
||||||
}
|
}
|
||||||
|
|
@ -504,17 +509,54 @@ extension InteractionView: UICollectionViewDelegate {
|
||||||
let page = Int(scrollView.contentOffset.x / scrollView.bounds.width)
|
let page = Int(scrollView.contentOffset.x / scrollView.bounds.width)
|
||||||
emojiPageControl.currentPage = page
|
emojiPageControl.currentPage = page
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||||
|
guard collectionView == emojiCollectionView else { return }
|
||||||
|
(cell as? EmojiPanelCell)?.stopAnimation()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - EmojiPanelCell
|
// MARK: - EmojiPanelCell
|
||||||
final class EmojiPanelCell: UICollectionViewCell {
|
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 = {
|
private let lottieView: LottieAnimationView = {
|
||||||
let v = LottieAnimationView()
|
let v = LottieAnimationView()
|
||||||
v.contentMode = .scaleAspectFit
|
v.contentMode = .scaleAspectFit
|
||||||
// v.loopMode = .loop
|
v.loopMode = .loop
|
||||||
return v
|
return v
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -547,28 +589,38 @@ final class EmojiPanelCell: UICollectionViewCell {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(path: String) {
|
func configure(name: String) {
|
||||||
|
animationName = name
|
||||||
|
shouldPlayAnimation = false
|
||||||
lottieView.stop()
|
lottieView.stop()
|
||||||
if let cached = Self.animationCache[path] {
|
lottieView.animation = nil
|
||||||
lottieView.animation = cached
|
|
||||||
} else {
|
|
||||||
lottieView.animation = LottieAnimation.filepath(path)
|
|
||||||
}
|
|
||||||
lottieView.currentProgress = 0
|
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
|
lockView.isHidden = AppContextManager.shared.vip > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func playAnimation() {
|
func playAnimation() {
|
||||||
|
shouldPlayAnimation = true
|
||||||
lottieView.play()
|
lottieView.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopAnimation() {
|
func stopAnimation() {
|
||||||
|
shouldPlayAnimation = false
|
||||||
lottieView.stop()
|
lottieView.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
|
animationName = nil
|
||||||
|
shouldPlayAnimation = false
|
||||||
lottieView.stop()
|
lottieView.stop()
|
||||||
lottieView.animation = nil
|
lottieView.animation = nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ class LaunchViewController: BaseViewController {
|
||||||
view.backgroundColor = UIColor(hexStr: "#E0F2FF")
|
view.backgroundColor = UIColor(hexStr: "#E0F2FF")
|
||||||
setupLayout()
|
setupLayout()
|
||||||
|
|
||||||
GroupIMService.shared.initSDK()
|
|
||||||
|
|
||||||
experienceBtn.rx.tap.subscribe(onNext: { _ in
|
experienceBtn.rx.tap.subscribe(onNext: { _ in
|
||||||
// Defaults[\.guideShowVersion] = self.showVersion
|
// Defaults[\.guideShowVersion] = self.showVersion
|
||||||
AppDelegate.shared.showMainViewController()
|
AppDelegate.shared.showMainViewController()
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ final class MineView: UIView {
|
||||||
guard model.vip > 1 else {
|
guard model.vip > 1 else {
|
||||||
vipTitleLab.text = "开通会员"
|
vipTitleLab.text = "开通会员"
|
||||||
vipContentLab.text = "会员尊享10+VIP特权"
|
vipContentLab.text = "会员尊享10+VIP特权"
|
||||||
|
vipActivateButton.isHidden = false
|
||||||
vipActivateButton.setTitle("立即开通", for: .normal)
|
vipActivateButton.setTitle("立即开通", for: .normal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +41,7 @@ final class MineView: UIView {
|
||||||
vipContentLab.text = model.vip == 3 ? "会员终身有效"
|
vipContentLab.text = model.vip == 3 ? "会员终身有效"
|
||||||
: "会员到期:\(self.getDateInterval2String(date: model.vip_expire_time, dateFormat: "yyyy.MM.dd"))"
|
: "会员到期:\(self.getDateInterval2String(date: model.vip_expire_time, dateFormat: "yyyy.MM.dd"))"
|
||||||
vipActivateButton.setTitle("点击续费", for: .normal)
|
vipActivateButton.setTitle("点击续费", for: .normal)
|
||||||
|
vipActivateButton.isHidden = model.vip == 3
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupRx() {
|
private func setupRx() {
|
||||||
|
|
@ -142,6 +144,7 @@ final class MineView: UIView {
|
||||||
btn.setTitleColor(UIColor(hexStr: "#00343B"), for: .normal)
|
btn.setTitleColor(UIColor(hexStr: "#00343B"), for: .normal)
|
||||||
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .bold)
|
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .bold)
|
||||||
btn.cornerRadius = 14
|
btn.cornerRadius = 14
|
||||||
|
btn.isHidden = true
|
||||||
return btn
|
return btn
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||