- 图片消息(收发、查看大图)

- 聊天底部菜单每个按钮点击交互
- 圈子设置页面
This commit is contained in:
linshujie 2026-06-09 18:34:24 +08:00
parent 59be003fc7
commit 5381d1ffda
19 changed files with 2286 additions and 131 deletions

View File

@ -191,6 +191,11 @@
30DC18602FD12A020041DCD1 /* VipWaivePopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30DC185F2FD12A020041DCD1 /* VipWaivePopView.swift */; };
30EFF2992FD65FB000EB35D4 /* VoicePlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF2982FD65FB000EB35D4 /* VoicePlayerManager.swift */; };
30EFF29B2FD668C900EB35D4 /* VoiceRecordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF29A2FD668C900EB35D4 /* VoiceRecordView.swift */; };
30EFF3A42FD7C5A300EB35D4 /* GroupSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF3A32FD7C5A300EB35D4 /* GroupSettingView.swift */; };
30EFF3A62FD7C5AF00EB35D4 /* GroupSettingVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF3A52FD7C5AF00EB35D4 /* GroupSettingVC.swift */; };
30EFF3A82FD7C6A400EB35D4 /* GroupSettingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF3A72FD7C6A400EB35D4 /* GroupSettingViewModel.swift */; };
30EFF3AE2FD7FF1400EB35D4 /* TextInputViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF3AC2FD7FF1400EB35D4 /* TextInputViewController.swift */; };
30EFF3B02FD8122E00EB35D4 /* GroupTagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF3AF2FD8122E00EB35D4 /* GroupTagListView.swift */; };
C49B37352A45A02C28FF41BA /* Pods_QuickLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D1C77B42994F352054070537 /* Pods_QuickLocation.framework */; };
/* End PBXBuildFile section */
@ -387,6 +392,13 @@
30DC185F2FD12A020041DCD1 /* VipWaivePopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VipWaivePopView.swift; sourceTree = "<group>"; };
30EFF2982FD65FB000EB35D4 /* VoicePlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoicePlayerManager.swift; sourceTree = "<group>"; };
30EFF29A2FD668C900EB35D4 /* VoiceRecordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRecordView.swift; sourceTree = "<group>"; };
30EFF3A02FD7A47900EB35D4 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = "zh-Hans"; path = "zh-Hans.lproj/LaunchScreen.storyboard"; sourceTree = "<group>"; };
30EFF3A12FD7A47900EB35D4 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = "zh-Hans"; path = "zh-Hans.lproj/Main.storyboard"; sourceTree = "<group>"; };
30EFF3A32FD7C5A300EB35D4 /* GroupSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupSettingView.swift; sourceTree = "<group>"; };
30EFF3A52FD7C5AF00EB35D4 /* GroupSettingVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupSettingVC.swift; sourceTree = "<group>"; };
30EFF3A72FD7C6A400EB35D4 /* GroupSettingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupSettingViewModel.swift; sourceTree = "<group>"; };
30EFF3AC2FD7FF1400EB35D4 /* TextInputViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputViewController.swift; sourceTree = "<group>"; };
30EFF3AF2FD8122E00EB35D4 /* GroupTagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupTagListView.swift; sourceTree = "<group>"; };
3E4359082FC48D26003470A5 /* QuickLocation.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QuickLocation.app; sourceTree = BUILT_PRODUCTS_DIR; };
93647DF3683AA5E71EC2FB1A /* Pods-QuickLocation.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuickLocation.release.xcconfig"; path = "Target Support Files/Pods-QuickLocation/Pods-QuickLocation.release.xcconfig"; sourceTree = "<group>"; };
D1C77B42994F352054070537 /* Pods_QuickLocation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_QuickLocation.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -396,8 +408,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
3070777D2FD2A214004C37CC /* lotties */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = lotties;
sourceTree = "<group>";
};
@ -759,6 +769,7 @@
305A76252FCA8C7000227D26 /* GroupViewModel.swift */,
305A76232FCA8C7000227D26 /* GroupView.swift */,
307073E42FD18A20004C37CC /* GroupChat */,
30EFF3A22FD7C58400EB35D4 /* GroupSetting */,
3062E8B82FCEAC5600CEF511 /* CreateGroup */,
30BAB8612FCD714700C33B5C /* Join */,
30BAB84B2FCD2FA400C33B5C /* InviteJoin */,
@ -830,6 +841,7 @@
3062E8C52FCFD01000CEF511 /* VipRecharge */,
3062E8B32FCE6BA400CEF511 /* Scan */,
30DC18592FD11E7A0041DCD1 /* Web */,
30EFF3AD2FD7FF1400EB35D4 /* TextInput */,
);
path = Section;
sourceTree = "<group>";
@ -1079,6 +1091,25 @@
path = Web;
sourceTree = "<group>";
};
30EFF3A22FD7C58400EB35D4 /* GroupSetting */ = {
isa = PBXGroup;
children = (
30EFF3A52FD7C5AF00EB35D4 /* GroupSettingVC.swift */,
30EFF3A32FD7C5A300EB35D4 /* GroupSettingView.swift */,
30EFF3A72FD7C6A400EB35D4 /* GroupSettingViewModel.swift */,
30EFF3AF2FD8122E00EB35D4 /* GroupTagListView.swift */,
);
path = GroupSetting;
sourceTree = "<group>";
};
30EFF3AD2FD7FF1400EB35D4 /* TextInput */ = {
isa = PBXGroup;
children = (
30EFF3AC2FD7FF1400EB35D4 /* TextInputViewController.swift */,
);
path = TextInput;
sourceTree = "<group>";
};
3E4358FF2FC48D26003470A5 = {
isa = PBXGroup;
children = (
@ -1148,11 +1179,11 @@
};
};
buildConfigurationList = 3E4359032FC48D26003470A5 /* Build configuration list for PBXProject "QuickLocation" */;
developmentRegion = en;
developmentRegion = "zh-Hans";
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
);
mainGroup = 3E4358FF2FC48D26003470A5;
minimizedProjectReferenceProxies = 1;
@ -1216,10 +1247,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-frameworks.sh\"\n";
@ -1233,10 +1268,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-resources.sh\"\n";
@ -1253,6 +1292,7 @@
305A76892FCA8C7000227D26 /* Observable+Response.swift in Sources */,
305A768A2FCA8C7000227D26 /* Single+Response.swift in Sources */,
305A768B2FCA8C7000227D26 /* API.swift in Sources */,
30EFF3AE2FD7FF1400EB35D4 /* TextInputViewController.swift in Sources */,
305A768C2FCA8C7000227D26 /* APIProvider.swift in Sources */,
3062E8C92FCFD03B00CEF511 /* VipRechargeVC.swift in Sources */,
305A768D2FCA8C7000227D26 /* AppNetworkConfig.swift in Sources */,
@ -1279,6 +1319,7 @@
305A769F2FCA8C7000227D26 /* TextContentArrowCell.swift in Sources */,
305A76A02FCA8C7000227D26 /* TextTableViewCell.swift in Sources */,
305A76A12FCA8C7000227D26 /* UIButton+RTL.m in Sources */,
30EFF3A62FD7C5AF00EB35D4 /* GroupSettingVC.swift in Sources */,
305A76A22FCA8C7000227D26 /* Array+Extension.swift in Sources */,
305A76A32FCA8C7000227D26 /* ControlEvents+Block.swift in Sources */,
3062E8B72FCE6BFE00CEF511 /* ScanView.swift in Sources */,
@ -1303,6 +1344,7 @@
305A76B42FCA8C7000227D26 /* UINavigationController+FDFullscreenPopGesture.m in Sources */,
305A76B52FCA8C7000227D26 /* UITableView+Extension.swift in Sources */,
305A76B62FCA8C7000227D26 /* UITextField+Extensions.swift in Sources */,
30EFF3A82FD7C6A400EB35D4 /* GroupSettingViewModel.swift in Sources */,
305A76B72FCA8C7000227D26 /* UIView+Extension.swift in Sources */,
305A76B82FCA8C7000227D26 /* UIViewController+Extension.swift in Sources */,
305A76B92FCA8C7000227D26 /* URL+Extension.swift in Sources */,
@ -1343,6 +1385,7 @@
305A76D62FCA8C7000227D26 /* ImagePlugin.swift in Sources */,
305A76D72FCA8C7000227D26 /* NotEmpty.swift in Sources */,
305A76D82FCA8C7000227D26 /* Action.swift in Sources */,
30EFF3B02FD8122E00EB35D4 /* GroupTagListView.swift in Sources */,
305A76D92FCA8C7000227D26 /* Action+Internal.swift in Sources */,
305A76DA2FCA8C7000227D26 /* Button+Action.swift in Sources */,
305A76DB2FCA8C7000227D26 /* Control+Action.swift in Sources */,
@ -1384,6 +1427,7 @@
305A76F32FCA8C7000227D26 /* AutoLayout+NSLayoutConstraint.swift in Sources */,
305A76F42FCA8C7000227D26 /* AutoLayout+UIView.swift in Sources */,
305A76F52FCA8C7000227D26 /* AutoLayoutSwift.swift in Sources */,
30EFF3A42FD7C5A300EB35D4 /* GroupSettingView.swift in Sources */,
305A76F62FCA8C7000227D26 /* AppRouter.swift in Sources */,
305A76F72FCA8C7000227D26 /* RouterTarget.swift in Sources */,
307073E52FD18A20004C37CC /* GroupChatView.swift in Sources */,
@ -1434,6 +1478,7 @@
isa = PBXVariantGroup;
children = (
305A76812FCA8C7000227D26 /* Base */,
30EFF3A02FD7A47900EB35D4 /* zh-Hans */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
@ -1442,6 +1487,7 @@
isa = PBXVariantGroup;
children = (
305A76832FCA8C7000227D26 /* Base */,
30EFF3A12FD7A47900EB35D4 /* zh-Hans */,
);
name = Main.storyboard;
sourceTree = "<group>";
@ -1463,6 +1509,12 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = QuickLocation/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "极速定位";
INFOPLIST_KEY_NSCameraUsageDescription = "您的相机将被用于扫描二维码、拍摄照片和视频。";
INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "需要获取您的位置信息以在地图上显示您的位置";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息以在地图上显示您的位置";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "我们需要使用您的麦克风,以便您使用麦克风进行音频录制。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "为了保存图片到您的相册,请允许添加照片,谢谢。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "我们需要访问您的相册,以便您可以发送照片。";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main;
@ -1505,6 +1557,12 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = QuickLocation/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "极速定位";
INFOPLIST_KEY_NSCameraUsageDescription = "您的相机将被用于扫描二维码、拍摄照片和视频。";
INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "需要获取您的位置信息以在地图上显示您的位置";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息以在地图上显示您的位置";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "我们需要使用您的麦克风,以便您使用麦克风进行音频录制。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "为了保存图片到您的相册,请允许添加照片,谢谢。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "我们需要访问您的相册,以便您可以发送照片。";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main;
@ -1538,6 +1596,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@ -1601,6 +1660,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";

View File

@ -7,14 +7,6 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>您的相机将被用于扫描二维码、拍摄照片和视频。</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>需要获取您的位置信息以在地图上显示您的位置</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>需要获取您的位置信息以在地图上显示您的位置</string>
<key>NSMicrophoneUsageDescription</key>
<string>我们需要使用您的麦克风,以便您使用麦克风进行音频录制。</string>
<key>UIAppFonts</key>
<array>
<string>douyu.otf</string>

View File

@ -29,6 +29,8 @@ enum Route: String {
case vipRights = "vipRights"
///
case groupChat = "groupChat"
///
case groupSetting = "groupSetting"
}
extension Route: RouterTarget {
@ -149,10 +151,17 @@ extension AppRouter: AppRouterProtocol {
VipRightsVC()
}
// MARK: -
AppRouter.register(Route.groupChat) { url, parameters in
let groupId = parameters["groupId"].safeString
return GroupChatVC(groupId: groupId)
}
// MARK: -
AppRouter.register(Route.groupSetting) { url, parameters in
let groupId = parameters["groupId"].safeString
return GroupSettingVC(groupId: groupId)
}
}
}

View File

@ -60,7 +60,7 @@ public struct ThemeColor: Mappable {
/// #999999
public var contentColor = UIColor(hexStr: "#999999")
/// 线
public var lineColor = UIColor(hexStr: "#F2F2F2")
public var lineColor = UIColor(hexStr: "#EEEEEE")
///
public var backgroundColor = UIColor(hexStr: "#F2F2F2")
///

View File

@ -105,9 +105,13 @@ struct GroupInfoModel: Mappable, Equatable {
var groupIcon: UIImage {
UIImage(named: "GroupIcon/\(icon_index)") ?? UIImage()
}
///
var labels: [String] = []
///
var review: Bool = false
///
var people_no: Int = 0
///
///
var description: String = ""
///
var level: String = ""
@ -121,7 +125,10 @@ struct GroupInfoModel: Mappable, Equatable {
is_owner <- map["is_owner"]
name <- map["name"]
icon_index <- map["icon_index"]
labels <- map["labels"]
level <- map["level"]
review <- map["review"]
description <- map["description"]
people_no <- (map["people_no"], kStrTransformInt)
}
}

View File

@ -0,0 +1,311 @@
//
// TextInputViewController.swift
// QuickLocation
//
// Created by on 2026/6/9.
//
import UIKit
import RxSwift
import RxCocoa
///
///
/// let vc = TextInputViewController(title: "", maxLength: 20) { text in
/// print(": \(text)")
/// }
/// present(vc, animated: true)
final class TextInputViewController: UIViewController {
private let titleText: String
private let maxLength: Int
private let confirmAction: ((String) -> Void)?
private let disposeBag = DisposeBag()
private let textRelay = BehaviorRelay<String>(value: "")
// MARK: - Init
/// - Parameters:
/// - title:
/// - maxLength: 0
/// - initialText:
/// - confirmAction:
init(title: String,
maxLength: Int = 0,
initialText: String = "",
confirmAction: ((String) -> Void)? = nil) {
self.titleText = title
self.maxLength = maxLength
self.confirmAction = confirmAction
self.textRelay.accept(initialText)
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
modalTransitionStyle = .coverVertical
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(hexStr: "#F5FBFB")
setupUI()
setupBinding()
setupKeyboard()
textView.becomeFirstResponder()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if textViewHeightConstraint == nil {
updateTextViewHeight()
}
}
// MARK: - UI
private func setupUI() {
view.addSubview(topBar)
topBar.addSubview(closeBtn)
topBar.addSubview(titleLabel)
view.addSubview(contentView)
contentView.addSubview(textView)
textView.addSubview(countLabel)
contentView.addSubview(confirmBtn)
//
topBar.layoutChain
.top()
.edgesHorzontal()
.height(kNaviHeight)
closeBtn.layoutChain
.bottom(12)
.left(7)
.width(24).height(24)
titleLabel.layoutChain
.centerY(closeBtn)
.centerX()
// textView +
contentView.layoutChain
.topToBottomOfView(topBar, offset: 16)
.edgesHorzontal(15)
.bottom()
//
textView.layoutChain
.top()
.edgesHorzontal()
.height(textViewMinHeight)
// textView
countLabel.layoutChain
.right(-10)
.bottom(-8)
//
confirmBtn.layoutChain
.topToBottomOfView(textView, offset: 50)
.edgesHorzontal()
.height(44)
.bottom()
}
// MARK: - Binding
private func setupBinding() {
//
textView.rx.text
.compactMap { $0 }
.subscribe(onNext: { [weak self] text in
guard let self = self else { return }
let realText = self.maxLength > 0 && text.count > self.maxLength
? String(text.prefix(self.maxLength))
: text
if realText != text {
self.textView.text = realText
}
self.textRelay.accept(realText)
})
.disposed(by: disposeBag)
//
textRelay
.map { [weak self] text in
guard let self = self, self.maxLength > 0 else { return "" }
return "\(text.count)/\(self.maxLength)"
}
.bind(to: countLabel.rx.text)
.disposed(by: disposeBag)
// +
let confirmEnabled = textRelay
.map { [weak self] text in
guard let self = self else { return false }
if self.maxLength > 0 { return !text.isEmpty && text.count <= self.maxLength }
return !text.isEmpty
}
.share(replay: 1)
confirmEnabled
.bind(to: confirmBtn.rx.isEnabled)
.disposed(by: disposeBag)
confirmEnabled
.subscribe(onNext: { [weak self] enabled in
self?.confirmBtn.backgroundColor = enabled
? UIColor(hexStr: "#16B3FF")
: UIColor(hexStr: "#CCCCCC")
})
.disposed(by: disposeBag)
//
textView.rx.text
.observe(on: MainScheduler.asyncInstance)
.subscribe(onNext: { [weak self] _ in
self?.updateTextViewHeight()
})
.disposed(by: disposeBag)
//
confirmBtn.rx.tap
.withLatestFrom(textRelay)
.subscribe(onNext: { [weak self] text in
guard let self = self else { return }
self.confirmAction?(text)
self.dismiss(animated: true)
})
.disposed(by: disposeBag)
//
closeBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
self?.dismiss(animated: true)
})
.disposed(by: disposeBag)
}
// MARK: - Keyboard
private var originContentY: CGFloat = 0
private func setupKeyboard() {
NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification)
.subscribe(onNext: { [weak self] noti in
guard let self = self,
let userInfo = noti.userInfo,
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
let keyboardHeight = frame.height
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
// contentView = -
let offset = keyboardHeight - kSafeBottomMargin
UIView.animate(withDuration: duration) {
self.contentView.transform = CGAffineTransform(translationX: 0, y: -offset)
self.view.layoutIfNeeded()
}
})
.disposed(by: disposeBag)
NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification)
.subscribe(onNext: { [weak self] noti in
guard let self = self else { return }
let duration = (noti.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
UIView.animate(withDuration: duration) {
self.contentView.transform = .identity
self.view.layoutIfNeeded()
}
})
.disposed(by: disposeBag)
}
// MARK: - TextView Height
private let textViewMinHeight: CGFloat = 150
private let textViewMaxHeight: CGFloat = 300
private var textViewHeightConstraint: NSLayoutConstraint?
private func updateTextViewHeight() {
let size = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: CGFloat.greatestFiniteMagnitude))
let height = min(max(size.height, textViewMinHeight), textViewMaxHeight)
if textViewHeightConstraint == nil {
textViewHeightConstraint = textView.layoutChain.height(height)
} else {
textViewHeightConstraint?.constant = height
}
UIView.setAnimationsEnabled(false)
textView.layoutIfNeeded()
UIView.setAnimationsEnabled(true)
}
// MARK: - Views
private lazy var contentView: UIView = {
let v = UIView()
v.backgroundColor = .clear
return v
}()
private lazy var topBar: UIView = {
let v = UIView()
v.backgroundColor = .clear
return v
}()
private lazy var closeBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "Common/back"), for: .normal)
btn.extendEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 30)
return btn
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 17, weight: .medium)
label.textColor = UIColor(hexStr: "#333333")
label.text = titleText
label.textAlignment = .center
return label
}()
lazy var textView: UITextView = {
let tv = UITextView()
tv.font = .systemFont(ofSize: 15)
tv.textColor = UIColor(hexStr: "#333333")
tv.backgroundColor = .white
tv.cornerRadius = 4
tv.layer.borderWidth = 1
tv.layer.borderColor = ThemeManager.shared.color.lineColor.cgColor
tv.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
tv.showsVerticalScrollIndicator = true
tv.bounces = false
tv.tintColor = UIColor(hexStr: "#16B3FF")
return tv
}()
private lazy var countLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 12)
label.textColor = UIColor(hexStr: "#BBBBBB")
label.text = maxLength > 0 ? "0/\(maxLength)" : ""
return label
}()
private lazy var confirmBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setTitle("确定", for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.setTitleColor(.white, for: .disabled)
btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
btn.cornerRadius = 22
btn.isEnabled = false
return btn
}()
}

View File

@ -48,6 +48,5 @@ class GroupIconListVC: BaseViewController {
extension GroupIconListVC: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
rootView.selectedIndex = indexPath.row + 1
onSelectIcon?(rootView.selectedIndex)
}
}

View File

@ -12,6 +12,8 @@ import RxDataSources
import OpenIMSDK
import AVFoundation
import AudioToolbox
import HXPHPicker
import IQKeyboardManagerSwift
final class GroupChatVC: BaseViewController {
@ -45,6 +47,49 @@ final class GroupChatVC: BaseViewController {
setupMessageListener()
setupVoiceRecording()
setupPanelDismiss()
setupKeyboard()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
IQKeyboardManager.shared.isEnabled = false
IQKeyboardManager.shared.resignOnTouchOutside = false
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// if !hasScrolledToBottom {
// hasScrolledToBottom = true
// scrollToBottom()
// }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
VoicePlayerManager.shared.stop()
IQKeyboardManager.shared.isEnabled = true
IQKeyboardManager.shared.resignOnTouchOutside = true
}
// MARK: - Keyboard
private func setupKeyboard() {
//
NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification)
.subscribe(onNext: { [weak self] noti in
guard let self = self,
let userInfo = noti.userInfo,
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
let height = frame.height
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
// /
self.rootView.dismissAllPanels(excludeTextField: true)
UIView.animate(withDuration: duration) {
self.rootView.bottomBar.layoutChain.bottom(height + kSafeBottomMargin + 20)
}
self.scrollToBottom()
})
.disposed(by: disposeBag)
}
private func setupPanelDismiss() {
@ -89,19 +134,6 @@ final class GroupChatVC: BaseViewController {
private var hasScrolledToBottom = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !hasScrolledToBottom {
hasScrolledToBottom = true
scrollToBottom()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
VoicePlayerManager.shared.stop()
}
private func scrollToBottom() {
let count = dataSource.sectionModels.first?.items.count ?? 0
guard count > 0 else { return }
@ -112,50 +144,6 @@ final class GroupChatVC: BaseViewController {
}
}
// MARK: - dataSource
private lazy var dataSource: RxTableViewSectionedReloadDataSource<ChatSectionModel> = {
RxTableViewSectionedReloadDataSource<ChatSectionModel> { _, tableView, indexPath, item in
switch item {
case let .send(msg):
let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .received(msg):
let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .emojiSend(msg):
let cell: EmojiSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .emojiReceived(msg):
let cell: EmojiReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .voiceSend(msg):
let cell: VoiceSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .voiceReceived(msg):
let cell: VoiceReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .imageSend(msg):
let cell: ImageSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .imageReceived(msg):
let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .notification(text):
let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(text)
return cell
}
}
}()
private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
@ -183,25 +171,58 @@ final class GroupChatVC: BaseViewController {
})
.disposed(by: disposeBag)
//
rootView.voiceBtn.rx.tap.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
let status = AVCaptureDevice.authorizationStatus(for: .audio)
switch status {
case .authorized:
break
case .notDetermined:
AVAudioSession.sharedInstance().requestRecordPermission { granted in
guard granted else { return }
DispatchQueue.main.async {
self.rootView.dismissAllPanels()
self.showSpeakPanel()
}
}
return
default:
Permission.openAppSetting(title: "请开启麦克风权限",
message: "请在iPhone的“设置-隐私-麦克风”选项中允许\(kAppName)访问你的麦克风。")
return
}
self.rootView.dismissAllPanels()
self.showSpeakPanel()
})
.disposed(by: disposeBag)
//
rootView.voiceRecordView.keyboardBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
self.rootView.dismissAllPanels()
self.rootView.textField.becomeFirstResponder()
})
.disposed(by: disposeBag)
//
rootView.emojiBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
let show = self.rootView.emojiPanelView.isHidden
self.rootView.emojiPanelView.isHidden = !show
let offset: CGFloat = show ? 220 : 0
UIView.animate(withDuration: 0.25) {
self.rootView.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20 + offset)
} completion: { _ in
let offset: CGFloat = self.rootView.tableView.contentSize.height
self.rootView.tableView.setContentOffset(CGPointMake(0, offset), animated: false)
}
if show {
self.rootView.textField.resignFirstResponder()
EmojiPanelCell.preloadAnimations()
}
self.showEmojiPanel()
})
.disposed(by: disposeBag)
rootView.voiceRecordView.emojiBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
self.rootView.dismissAllPanels()
self.showEmojiPanel()
})
.disposed(by: disposeBag)
//
rootView.emojiCollectionView.rx.modelSelected(String.self)
.subscribe(onNext: { [weak self] name in
guard let self = self, let idx = UITableViewCell.emojiFileNames.firstIndex(of: name) else { return }
@ -209,8 +230,18 @@ final class GroupChatVC: BaseViewController {
})
.disposed(by: disposeBag)
//
Observable.merge(
rootView.addBtn.rx.tap.asObservable(),
rootView.voiceRecordView.addBtn.rx.tap.asObservable()
)
.subscribe(onNext: { [weak self] in
guard let self = self else { return }
self.rootView.dismissAllPanels()
self.showAlbum()
})
.disposed(by: disposeBag)
let sendText = Observable.merge(
rootView.sendBtn.rx.tap.map { [weak self] _ in self?.rootView.textField.text ?? "" },
rootView.textField.rx.controlEvent(.editingDidEndOnExit)
@ -222,6 +253,65 @@ final class GroupChatVC: BaseViewController {
sendText
.bind(to: viewModel.input.sendMessage)
.disposed(by: disposeBag)
//
rootView.settingBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
AppRouter.push(Route.groupSetting, userInfo: ["groupId": self.viewModel.groupId])
})
.disposed(by: disposeBag)
}
// MARK: -
private func showSpeakPanel() {
let show = rootView.voiceRecordView.isHidden
rootView.voiceRecordView.isHidden = !show
let offset: CGFloat = show ? 252 : 0
UIView.animate(withDuration: 0.25) {
self.rootView.bottomBar.layoutChain.bottom(show ? offset - self.rootView.bottomBar.dl.height : kSafeBottomMargin + 20)
self.rootView.voiceRecordView.layoutChain.bottom(offset - 252 + kSafeBottomMargin)
}
scrollToBottom()
}
// MARK: -
private func showEmojiPanel() {
let show = self.rootView.emojiPanelView.isHidden
self.rootView.emojiPanelView.isHidden = !show
let offset: CGFloat = show ? 220 : 0
UIView.animate(withDuration: 0.25) {
self.rootView.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20 + offset)
}
// completion: { _ in
// let offset: CGFloat = self.rootView.tableView.contentSize.height
// self.rootView.tableView.setContentOffset(CGPointMake(0, offset), animated: false)
// }
scrollToBottom()
if show {
self.rootView.textField.resignFirstResponder()
EmojiPanelCell.preloadAnimations()
}
}
// MARK: -
private func showAlbum() {
//
let config = PhotoTools.getWXPickerConfig()
// 0
config.selectOptions = [.photo]
config.selectMode = .multiple
config.maximumSelectedCount = 9
// config.maximumSelectedPhotoFileSize = 5242880
config.allowSyncICloudWhenSelectPhoto = false
config.previewView.bottomView.editButtonHidden = true
config.photoList.allowAddCamera = true
config.photoList.camera.allowsEditing = false
let pickerController = PhotoPickerController(picker: config)
pickerController.pickerDelegate = self
pickerController.modalPresentationStyle = .fullScreen
self.present(pickerController, animated: true, completion: nil)
}
// MARK: - Voice Recording
@ -331,6 +421,56 @@ final class GroupChatVC: BaseViewController {
self.viewModel.loadMessages()
}.disposed(by: disposeBag)
}
// MARK: - dataSource
private lazy var dataSource: RxTableViewSectionedReloadDataSource<ChatSectionModel> = {
RxTableViewSectionedReloadDataSource<ChatSectionModel> { _, tableView, indexPath, item in
switch item {
case let .send(msg):
let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .received(msg):
let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .emojiSend(msg):
let cell: EmojiSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .emojiReceived(msg):
let cell: EmojiReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .voiceSend(msg):
let cell: VoiceSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .voiceReceived(msg):
let cell: VoiceReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
return cell
case let .imageSend(msg):
let cell: ImageSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onImageTap = { [weak self] in
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
}
return cell
case let .imageReceived(msg):
let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onImageTap = { [weak self] in
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
}
return cell
case let .notification(text):
let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(text)
return cell
}
}
}()
}
// MARK: - MessageListenerProxy
@ -345,3 +485,97 @@ private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener {
handler(msg)
}
}
// MARK: - PhotoPickerControllerDelegate
extension GroupChatVC: PhotoPickerControllerDelegate {
///
/// - Parameters:
/// - pickerController: PhotoPickerController
/// - result:
/// result.photoAssets
/// result.isOriginal
func pickerController(_ pickerController: PhotoPickerController,
didFinishSelection result: PickerResult) {
result.getImage { (image, photoAsset, index) in
} completionHandler: { [weak self] (images) in
guard let self = self else { return }
for img in images {
self.sendImageMessage(img)
}
}
}
private func sendImageMessage(_ image: UIImage) {
guard let data = image.jpegData(compressionQuality: 0.8) else { return }
let dir = NSTemporaryDirectory()
let filename = "img_\(Int(Date().timeIntervalSince1970)).jpg"
let fileURL = URL(fileURLWithPath: dir + filename)
try? data.write(to: fileURL)
let displaySize = Self.imageDisplaySize(w: image.size.width, h: image.size.height)
let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path)
// 使 SDK clientMsgID ID便
let localId = msg.clientMsgID ?? UUID().uuidString
// loading
let localMsg = ChatMessage(
id: localId,
isSelf: true,
avatar: viewModel.getUserAvatar(id: AppContextManager.shared.userId),
senderName: AppContextManager.shared.name,
content: "",
voiceUrl: "",
imageUrl: fileURL.path,
imageWidth: displaySize.width,
imageHeight: displaySize.height,
timestamp: Date().timeIntervalSince1970,
showTime: false,
isUploading: true
)
viewModel.appendLocalMessage(.imageSend(localMsg))
OIMManager.manager.sendMessage(msg,
recvID: "",
groupID: viewModel.groupId,
offlinePushInfo: nil,
onSuccess: { [weak self] returnedMsg in
// URLloading
// returnedMsg SDK OIMMessageInfo URL
let networkUrl = returnedMsg?.pictureElem?.sourcePicture?.url
?? returnedMsg?.pictureElem?.bigPicture?.url
?? returnedMsg?.pictureElem?.sourcePath
?? ""
self?.viewModel.updateLocalMessage(id: localId) { chatMsg in
// URL
if !networkUrl.isEmpty {
chatMsg.imageUrl = networkUrl
}
chatMsg.isUploading = false
}
try? FileManager.default.removeItem(at: fileURL)
},
onProgress: nil as OIMNumberCallback?,
onFailure: { [weak self] code, errMsg in
print("Image send failed: \(code) \(errMsg ?? "")")
// loading
self?.viewModel.updateLocalMessage(id: localId) { chatMsg in
chatMsg.isUploading = false
}
})
}
private static func imageDisplaySize(w: CGFloat, h: CGFloat) -> CGSize {
guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) }
let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80
var dw = maxW, dh = dw * (h / w)
if dh > maxH { dh = maxH; dw = dh * (w / h) }
if dw < minW { dw = minW; dh = dw * (h / w) }
return CGSize(width: dw, height: dh)
}
///
/// - Parameter pickerController: PhotoPickerController
func pickerController(didCancel pickerController: PhotoPickerController) {
}
}

View File

@ -25,11 +25,12 @@ struct ChatMessage {
let senderName: String
let content: String
let voiceUrl: String
let imageUrl: String
var imageUrl: String
let imageWidth: CGFloat
let imageHeight: CGFloat
let timestamp: TimeInterval
var showTime: Bool = false
var isUploading: Bool = false
}
class GroupChatView: UIView {
@ -44,54 +45,22 @@ class GroupChatView: UIView {
var onVoiceRecordState: ((VoiceRecordState) -> Void)?
private func setupRx() {
voiceBtn.rx.tap.subscribe(onNext: { [weak self] _ in
guard let self = self else { return }
let status = AVCaptureDevice.authorizationStatus(for: .audio)
switch status {
case .authorized:
break
case .notDetermined:
AVAudioSession.sharedInstance().requestRecordPermission { granted in
guard granted else { return }
DispatchQueue.main.async {
self.toggleVoicePanel()
}
}
return
default:
Permission.openAppSetting(title: "请开启麦克风权限",
message: "请在iPhone的“设置-隐私-麦克风”选项中允许\(kAppName)访问你的麦克风。")
return
}
self.toggleVoicePanel()
})
.disposed(by: disposeBag)
}
private func toggleVoicePanel() {
let show = voiceRecordView.isHidden
voiceRecordView.isHidden = !show
let offset: CGFloat = show ? 252 : 0
UIView.animate(withDuration: 0.25) {
self.bottomBar.layoutChain.bottom(show ? offset - self.bottomBar.dl.height : kSafeBottomMargin + 20)
self.voiceRecordView.layoutChain.bottom(offset - 252 + kSafeBottomMargin)
} completion: { _ in
let offset: CGFloat = self.tableView.contentSize.height
self.tableView.setContentOffset(CGPointMake(0, offset), animated: false)
}
}
///
func dismissAllPanels() {
let needsReset = !emojiPanelView.isHidden || !voiceRecordView.isHidden || !voiceRecordView.isHidden
func dismissAllPanels(excludeTextField: Bool = false) {
let needsReset = !emojiPanelView.isHidden
|| !voiceRecordView.isHidden
|| textField.isFirstResponder
guard needsReset else { return }
emojiPanelView.isHidden = true
voiceRecordView.isHidden = true
textField.resignFirstResponder()
if !excludeTextField { textField.resignFirstResponder() }
UIView.animate(withDuration: 0.25) {
self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20)
self.voiceRecordView.layoutChain.bottom(-252)
self.layoutIfNeeded()
}
}
@ -1226,16 +1195,34 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
// MARK: -
final class ImageSendMsgCell: UITableViewCell {
var onImageTap: (() -> Void)?
func configure(_ msg: ChatMessage) {
timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
if !msg.imageUrl.isEmpty {
photoView.dl.setImage(with: msg.imageUrl)
//
if let localImage = UIImage(contentsOfFile: msg.imageUrl) {
photoView.image = localImage
} else {
photoView.dl.setImage(with: msg.imageUrl)
}
}
photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight)
loadingView.isHidden = !msg.isUploading
if msg.isUploading { loadingView.startAnimating() }
}
@objc private func onTap() { onImageTap?() }
private let loadingView: UIActivityIndicatorView = {
let v = UIActivityIndicatorView(style: UIActivityIndicatorView.Style.large)
v.hidesWhenStopped = true
v.color = UIColor(hexStr: "#16B3FF")
return v
}()
private static func displaySize(w: CGFloat, h: CGFloat) -> CGSize {
guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) }
let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80
@ -1276,12 +1263,13 @@ final class ImageSendMsgCell: UITableViewCell {
return iv
}()
private let photoView: UIImageView = {
lazy var photoView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 8
iv.clipsToBounds = true
iv.backgroundColor = UIColor(hexStr: "#F0F0F0")
iv.isUserInteractionEnabled = true
return iv
}()
@ -1290,8 +1278,13 @@ final class ImageSendMsgCell: UITableViewCell {
selectionStyle = .none
backgroundColor = .clear
contentView.addSubview(timeLabel)
contentView.addSubview(avatarView)
contentView.addSubview(photoView)
contentView.addSubview(avatarView)
let tap = UITapGestureRecognizer(target: self, action: #selector(onTap))
photoView.addGestureRecognizer(tap)
photoView.addSubview(loadingView)
loadingView.layoutChain.centerX().centerY()
timeLabel.layoutChain.top().centerX()
avatarView.layoutChain
@ -1312,6 +1305,8 @@ final class ImageSendMsgCell: UITableViewCell {
// MARK: -
final class ImageReceivedMsgCell: UITableViewCell {
var onImageTap: (() -> Void)?
func configure(_ msg: ChatMessage) {
timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
@ -1323,6 +1318,8 @@ final class ImageReceivedMsgCell: UITableViewCell {
photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight)
}
@objc private func onTap() { onImageTap?() }
private static func displaySize(w: CGFloat, h: CGFloat) -> CGSize {
guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) }
let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80
@ -1370,12 +1367,13 @@ final class ImageReceivedMsgCell: UITableViewCell {
return label
}()
private let photoView: UIImageView = {
lazy var photoView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 8
iv.clipsToBounds = true
iv.backgroundColor = UIColor(hexStr: "#F0F0F0")
iv.isUserInteractionEnabled = true
return iv
}()
@ -1388,8 +1386,11 @@ final class ImageReceivedMsgCell: UITableViewCell {
contentView.addSubview(photoView)
contentView.addSubview(avatarView)
let tap = UITapGestureRecognizer(target: self, action: #selector(onTap))
photoView.addGestureRecognizer(tap)
timeLabel.layoutChain.top().centerX()
photoView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.width(160).height(160)

View File

@ -134,11 +134,44 @@ final class GroupChatViewModel {
})
}
///
func appendLocalMessage(_ item: ChatSectionItem) {
var items = messagesSubject.value
items.append(item)
messagesSubject.accept(items)
}
/// id /
func updateLocalMessage(id: String, update: (inout ChatMessage) -> Void) {
var items = messagesSubject.value
guard let idx = items.firstIndex(where: { item in
switch item {
case let .imageSend(m): return m.id == id
case let .voiceSend(m): return m.id == id
case let .send(m): return m.id == id
case let .emojiSend(m): return m.id == id
default: return false
}
}),
var chatMsg = items[idx].chatMessage
else { return }
update(&chatMsg)
items[idx] = ChatSectionItem.with(chatMsg)
messagesSubject.accept(items)
}
// MARK: - Receive
func onReceiveMessage(_ msg: OIMMessageInfo) {
guard let item = toSectionItem(msg) else { return }
let ts = timestampFrom(item: item)
var items = messagesSubject.value
// clientMsgID
if let clientMsgID = msg.clientMsgID, !clientMsgID.isEmpty,
items.contains(where: { $0.chatMessage?.id == clientMsgID }) {
return
}
let showTime = items.isEmpty || ts - lastTimeGap >= timeGapThreshold
lastTimeGap = ts
items.append(showTime ? setShowTime(item, true) : item)
@ -284,3 +317,30 @@ final class GroupChatViewModel {
return displayStr
}
}
// MARK: - ChatSectionItem Helpers
extension ChatSectionItem {
/// ChatMessage case
var chatMessage: ChatMessage? {
switch self {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m):
return m
case .notification: return nil
}
}
/// ChatMessage case
static func with(_ msg: ChatMessage) -> ChatSectionItem {
if !msg.imageUrl.isEmpty {
return msg.isSelf ? .imageSend(msg) : .imageReceived(msg)
}
if msg.content.hasPrefix("js_emoji:") {
return msg.isSelf ? .emojiSend(msg) : .emojiReceived(msg)
}
if !msg.voiceUrl.isEmpty {
return msg.isSelf ? .voiceSend(msg) : .voiceReceived(msg)
}
return msg.isSelf ? .send(msg) : .received(msg)
}
}

View File

@ -0,0 +1,149 @@
//
// GroupSettingVC.swift
// QuickLocation
//
// Created by on 2026/6/9.
//
import UIKit
import RxSwift
import RxCocoa
import RxDataSources
import ObjectMapper
class GroupSettingVC: BaseViewController {
fileprivate var rootView: GroupSettingView!
override func loadView() {
rootView = GroupSettingView(frame: UIScreen.main.bounds)
view = rootView
}
private var viewModel: GroupSettingViewModel
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
reactiveAction()
requestGroupInfo()
}
// MARK: - Actions
private func reactiveAction() {
//
rootView.groupNameView.rx.tapGesture.subscribe { _ in
guard let model = self.viewModel.groupModel, model.is_owner else { return }
let vc = TextInputViewController(title: "圈子名称", maxLength: 10, initialText: model.name) { text in
self.requestEditName(text)
}
self.present(vc, animated: true, completion: nil)
}.disposed(by: disposeBag)
//
rootView.groupDescView.rx.tapGesture.subscribe { _ in
guard let model = self.viewModel.groupModel, model.is_owner else { return }
let vc = TextInputViewController(title: "圈子描述", maxLength: 40, initialText: model.description) { text in
self.requestEditDesc(text)
}
self.present(vc, animated: true, completion: nil)
}.disposed(by: disposeBag)
//
rootView.groupIconView.rx.tapGesture.subscribe { _ in
guard let model = self.viewModel.groupModel, model.is_owner else { return }
let vc = GroupIconListVC(iconIndex: "\(model.icon_index)")
vc.onSelectIcon = { index in
self.requestChangeIcon(index)
}
self.navigationController?.pushViewController(vc, animated: true)
}.disposed(by: disposeBag)
//
rootView.switchBtn.rx.isOn
.subscribe(onNext: { isOn in
guard let model = self.viewModel.groupModel, model.is_owner else { return }
self.requestChangeReview(isOn)
})
.disposed(by: disposeBag)
//
rootView.tagView.rx.tapGesture.subscribe { _ in
guard let model = self.viewModel.groupModel, model.is_owner else { return }
GroupTagListView.show(selectedTagList: model.labels) { tagList in
guard let model = self.viewModel.groupModel,
model.is_owner,
let list = tagList else { return }
self.requestEditLabels(labels: list)
}
}.disposed(by: disposeBag)
//
rootView.inviteView.rx.tapGesture.subscribe(onNext: { _ in
guard let model = self.viewModel.groupModel else { return }
AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": model.toJSON()])
}).disposed(by: disposeBag)
}
// MARK: - API
private func requestGroupInfo() {
DLToast.showLoading()
GroupService.groupInfoByKey(viewModel.groupId).subscribe { response in
DLToast.dismiss()
guard let model = response.model else { return }
self.viewModel.groupModel = model
self.rootView.setupData(model)
}.disposed(by: disposeBag)
}
private func requestChangeIcon(_ iconIndex: Int) {
DLToast.showLoading()
GroupService.changeIcon(requestData: ["group_key": viewModel.groupId, "icon_index": iconIndex]).subscribe { response in
DLToast.dismiss()
self.requestGroupInfo()
}.disposed(by: disposeBag)
}
private func requestChangeReview(_ review: Bool) {
DLToast.showLoading()
GroupService.changeReview(requestData: ["group_key": viewModel.groupId, "review": review]).subscribe { response in
DLToast.dismiss()
self.requestGroupInfo()
}.disposed(by: disposeBag)
}
private func requestEditName(_ name: String) {
DLToast.showLoading()
GroupService.editName(requestData: ["group_key": viewModel.groupId, "group_name": name]).subscribe { response in
DLToast.dismiss()
self.requestGroupInfo()
}.disposed(by: disposeBag)
}
private func requestEditDesc(_ desc: String) {
DLToast.showLoading()
GroupService.editDesc(requestData: ["group_key": viewModel.groupId, "description": desc]).subscribe { response in
DLToast.dismiss()
self.requestGroupInfo()
}.disposed(by: disposeBag)
}
private func requestEditLabels(labels: [String]) {
DLToast.showLoading()
GroupService.editLabels(requestData: ["group_key": viewModel.groupId, "labels": labels]).subscribe { response in
DLToast.dismiss()
self.requestGroupInfo()
}.disposed(by: disposeBag)
}
// MARK: - Init
init(groupId: String) {
self.viewModel = GroupSettingViewModel(groupId: groupId)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,651 @@
//
// GroupSettingView.swift
// QuickLocation
//
// Created by on 2026/6/9.
//
import UIKit
import RxSwift
import RxCocoa
import TagListView
class GroupSettingView: UIView {
var disposeBag = DisposeBag()
func setupData(_ model: GroupInfoModel) {
navTitleLabel.text = model.name
groupNameLab.text = model.name
groupIcon.image = model.groupIcon
groupDescLab.text = model.description.isEmpty ? "暂无描述" : model.description
switchBtn.isOn = model.review
tagListView.removeAllTags()
tagListView.addTags(model.labels)
tagListView.tagViews.forEach {
$0.layer.cornerRadius = 4
}
tagListView.invalidateIntrinsicContentSize() //
auditSwitchView.isHidden = !model.is_owner
groupNameEditIcon.isHidden = !model.is_owner
groupIconArrowIcon.isHidden = !model.is_owner
groupDescEditIcon.isHidden = !model.is_owner
groupTagArrowIcon.isHidden = !model.is_owner
auditMemberView.isHidden = !model.is_owner
removeMemberView.isHidden = !model.is_owner
dismissGroupView.isHidden = !model.is_owner
leaveGroupView.isHidden = model.is_owner
}
private func setupRx() {
backBtn.rx.tap.subscribe(onNext: { _ in
AppRouter.shared.popOrDismiss()
}).disposed(by: disposeBag)
}
private func setupUI() {
addSubview(navBgView)
addSubview(navBarView)
navBarView.addSubview(navTitleLabel)
navBarView.addSubview(backBtn)
addSubview(scrollView)
scrollView.addSubview(scrollContentView)
scrollContentView.addSubview(infoView)
infoView.addSubview(infoStackView)
scrollContentView.addSubview(groupManagerTitleLab)
scrollContentView.addSubview(groupManagerView)
groupManagerView.addSubview(groupManagerStackView)
navBgView.layoutChain
.edges(excludingEdge: .bottom)
.heightToWidth(160/375)
navBarView.layoutChain
.edges(excludingEdge: .bottom)
.height(kNaviHeight)
navTitleLabel.layoutChain
.top(kStatusBarHeight + 12)
.centerY(backBtn)
.centerX()
backBtn.layoutChain
.centerY(navTitleLabel)
.left(15)
.width(24)
.height(24)
scrollView.layoutChain
.topToBottomOfView(navBarView)
.edges(excludingEdge: .top)
scrollContentView.layoutChain
.edges()
.widthToView(scrollView)
infoView.layoutChain
.top(10)
.edgesHorzontal(15)
infoStackView.layoutChain
.edges()
groupNameView.layoutChain
.height(60)
groupIconView.layoutChain
.height(60)
auditSwitchView.layoutChain
.height(57)
groupManagerTitleLab.layoutChain
.topToBottomOfView(infoView, offset: 20)
.left(15)
groupManagerView.layoutChain
.topToBottomOfView(groupManagerTitleLab, offset: 15)
.edgesHorzontal(15)
.bottom(30)
groupManagerStackView.layoutChain
.edges()
inviteView.layoutChain.height(57)
auditMemberView.layoutChain.height(57)
removeMemberView.layoutChain.height(57)
dismissGroupView.layoutChain.height(57)
leaveGroupView.layoutChain.height(57)
}
lazy var navBgView: UIImageView = {
let iv = UIImageView()
iv.image = UIImage(named: "Common/navBar_bg_2")
iv.contentMode = .scaleAspectFill
return iv
}()
lazy var navBarView: UIView = {
let view = UIView()
view.backgroundColor = .clear
return view
}()
lazy var navTitleLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 18, weight: .medium)
label.textColor = ThemeManager.shared.color.titleAuxColor
label.textAlignment = .center
return label
}()
lazy var backBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "Common/back"), for: .normal)
btn.extendEdgeInsets = UIEdgeInsets(top: 54, left: 15, bottom: 100, right: 100)
return btn
}()
lazy var scrollView: UIScrollView = {
let view = UIScrollView()
view.backgroundColor = .clear
// view.showsVerticalScrollIndicator = false
// view.bounces = false
return view
}()
lazy var scrollContentView: UIView = {
let view = UIView()
view.backgroundColor = .clear
return view
}()
// MARK: -
lazy var infoView: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexStr: "#F5FBFF")
view.cornerRadius = 10
return view
}()
lazy var infoStackView: UIStackView = {
let view = UIStackView(arrangedSubviews: [groupNameView, groupIconView, groupDescView, auditSwitchView, tagView])
view.axis = .vertical
view.alignment = .fill
view.distribution = .fill
view.spacing = 0
view.backgroundColor = .clear
return view
}()
//
lazy var groupNameView: UIView = {
let view = UIView()
view.backgroundColor = .clear
let titleLab = UILabel()
titleLab.text = "圈子名称"
titleLab.textColor = ThemeManager.shared.color.titleAuxColor
titleLab.font = .systemFont(ofSize: 12, weight: .medium)
view.addSubview(titleLab)
titleLab.layoutChain
.left(15)
.width(50)
.centerY()
let line = UIView()
line.backgroundColor = ThemeManager.shared.color.lineColor
view.addSubview(line)
line.layoutChain
.bottom()
.height(0.5)
.edgesHorzontal(15)
view.addSubview(groupNameEditIcon)
groupNameEditIcon.layoutChain
.right(15)
.width(20)
.height(20)
.centerY()
view.addSubview(groupNameLab)
groupNameLab.layoutChain
.leftToRightOfView(titleLab, offset: 20)
.rightToLeftOfView(groupNameEditIcon, offset: -17, relation: .greaterThanOrEqual)
.centerY()
return view
}()
lazy var groupNameLab: UILabel = {
let label = UILabel()
label.textColor = ThemeManager.shared.color.titleAuxColor
label.font = .systemFont(ofSize: 14, weight: .medium)
return label
}()
lazy var groupNameEditIcon: UIImageView = {
let view = UIImageView(image: UIImage(named: "Group/edit"))
view.isHidden = true
return view
}()
//
lazy var groupIconView: UIView = {
let view = UIView()
view.backgroundColor = .clear
let titleLab = UILabel()
titleLab.text = "圈子图标"
titleLab.textColor = ThemeManager.shared.color.titleAuxColor
titleLab.font = .systemFont(ofSize: 12, weight: .medium)
view.addSubview(titleLab)
titleLab.layoutChain
.left(15)
.centerY()
let line = UIView()
line.backgroundColor = ThemeManager.shared.color.lineColor
view.addSubview(line)
line.layoutChain
.bottom()
.height(0.5)
.edgesHorzontal(15)
view.addSubview(groupIconArrowIcon)
groupIconArrowIcon.layoutChain
.right(15)
.width(14)
.height(14)
.centerY()
view.addSubview(groupIcon)
groupIcon.layoutChain
.leftToRightOfView(titleLab, offset: 20)
.centerY()
.width(40)
.heightToWidth(1)
return view
}()
lazy var groupIcon: UIImageView = {
let view = UIImageView()
view.backgroundColor = .clear
view.contentMode = .scaleAspectFill
view.cornerRadius = 10
return view
}()
lazy var groupIconArrowIcon: UIImageView = {
let view = UIImageView(image: UIImage(named: "Group/arrow"))
view.isHidden = true
return view
}()
//
lazy var groupDescView: UIView = {
let view = UIView()
view.backgroundColor = .clear
let titleLab = UILabel()
titleLab.text = "圈子描述"
titleLab.textColor = ThemeManager.shared.color.titleAuxColor
titleLab.font = .systemFont(ofSize: 12, weight: .medium)
view.addSubview(titleLab)
titleLab.layoutChain
.left(15)
.width(50)
.top(22)
let line = UIView()
line.backgroundColor = ThemeManager.shared.color.lineColor
view.addSubview(line)
line.layoutChain
.bottom()
.height(0.5)
.edgesHorzontal(15)
view.addSubview(groupDescEditIcon)
groupDescEditIcon.layoutChain
.right(15)
.width(20)
.height(20)
.centerY(titleLab)
view.addSubview(groupDescLab)
groupDescLab.layoutChain
.top(20)
.leftToRightOfView(titleLab, offset: 20)
.rightToLeftOfView(groupDescEditIcon, offset: -17)
.bottom(20)
return view
}()
lazy var groupDescLab: UILabel = {
let label = UILabel()
label.textColor = ThemeManager.shared.color.titleAuxColor
label.font = .systemFont(ofSize: 14, weight: .medium)
label.numberOfLines = 0
return label
}()
lazy var groupDescEditIcon: UIImageView = {
let view = UIImageView(image: UIImage(named: "Group/edit"))
view.isHidden = true
return view
}()
//
lazy var auditSwitchView: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.clipsToBounds = true
let titleLab = UILabel()
titleLab.text = "开启审核"
titleLab.textColor = ThemeManager.shared.color.titleAuxColor
titleLab.font = .systemFont(ofSize: 12, weight: .medium)
view.addSubview(titleLab)
titleLab.layoutChain
.left(15)
.centerY()
let line = UIView()
line.backgroundColor = ThemeManager.shared.color.lineColor
view.addSubview(line)
line.layoutChain
.bottom()
.height(0.5)
.edgesHorzontal(15)
view.addSubview(switchBtn)
switchBtn.layoutChain
.right(15)
.centerY()
.width(51)
.height(30)
view.isHidden = true
return view
}()
lazy var switchBtn: UISwitch = {
let view = UISwitch()
view.isOn = false
return view
}()
//
lazy var tagView: UIView = {
let view = UIView()
view.backgroundColor = .clear
let titleLab = UILabel()
titleLab.text = "圈子标签"
titleLab.textColor = ThemeManager.shared.color.titleAuxColor
titleLab.font = .systemFont(ofSize: 12, weight: .medium)
view.addSubview(titleLab)
titleLab.layoutChain
.left(15)
.width(50)
.top(20)
view.addSubview(groupTagArrowIcon)
groupTagArrowIcon.layoutChain
.right(15)
.width(14)
.height(14)
.centerY(titleLab)
view.addSubview(tagListView)
tagListView.layoutChain
.top(15)
.leftToRightOfView(titleLab, offset: 23)
.height(27, relation: .greaterThanOrEqual)
.rightToLeftOfView(groupTagArrowIcon, offset: -17)
let tipsLab = UILabel()
tipsLab.text = "如选择为私密圈子,将不能被分享到探索和被搜索。"
tipsLab.textColor = ThemeManager.shared.color.contentColor
tipsLab.font = .systemFont(ofSize: 10, weight: .regular)
view.addSubview(tipsLab)
tipsLab.layoutChain
.topToBottomOfView(tagListView, offset: 10)
.leftToView(titleLab)
.bottom(20)
return view
}()
lazy var tagListView: TagListView = {
let view = TagListView()
view.textFont = UIFont.systemFont(ofSize: 12, weight: .medium)
view.textColor = UIColor(hexStr: "#16B3FF")
view.tagBackgroundColor = UIColor(hexStr: "#E3F6FF")
view.paddingX = 20 //
view.paddingY = 6 //
view.alignment = .left //
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
lazy var groupTagArrowIcon: UIImageView = {
let view = UIImageView(image: UIImage(named: "Group/arrow"))
view.isHidden = true
return view
}()
// MARK: -
lazy var groupManagerTitleLab: UILabel = {
let label = UILabel()
label.text = "圈子管理"
label.textColor = ThemeManager.shared.color.titleAuxColor
label.font = .systemFont(ofSize: 16, weight: .semibold)
return label
}()
lazy var groupManagerView: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexStr: "#F5FBFF")
view.cornerRadius = 10
return view
}()
lazy var groupManagerStackView: UIStackView = {
let view = UIStackView(arrangedSubviews: [inviteView, auditMemberView, removeMemberView, dismissGroupView, leaveGroupView])
view.axis = .vertical
view.alignment = .fill
view.distribution = .fill
view.spacing = 0
view.backgroundColor = .clear
return view
}()
//
lazy var inviteView: UIView = {
let view = UIView()
view.backgroundColor = .clear
let titleLab = UILabel()
titleLab.text = "邀请成员"
titleLab.textColor = ThemeManager.shared.color.titleAuxColor
titleLab.font = .systemFont(ofSize: 12, weight: .medium)
view.addSubview(titleLab)
titleLab.layoutChain
.left(15)
.width(50)
.top(20)
let icon = UIImageView(image: UIImage(named: "Group/arrow"))
view.addSubview(icon)
icon.layoutChain
.right(15)
.width(14)
.height(14)
.centerY()
let line = UIView()
line.backgroundColor = ThemeManager.shared.color.lineColor
view.addSubview(line)
line.layoutChain
.bottom()
.height(0.5)
.edgesHorzontal(15)
return view
}()
//
lazy var auditMemberView = {
let view = UIView()
view.backgroundColor = .clear
view.isHidden = true
let titleLab = UILabel()
titleLab.text = "审核成员"
titleLab.textColor = ThemeManager.shared.color.titleAuxColor
titleLab.font = .systemFont(ofSize: 12, weight: .medium)
view.addSubview(titleLab)
titleLab.layoutChain
.left(15)
.width(50)
.top(20)
let icon = UIImageView(image: UIImage(named: "Group/arrow"))
view.addSubview(icon)
icon.layoutChain
.right(15)
.width(14)
.height(14)
.centerY()
let line = UIView()
line.backgroundColor = ThemeManager.shared.color.lineColor
view.addSubview(line)
line.layoutChain
.bottom()
.height(0.5)
.edgesHorzontal(15)
return view
}()
//
lazy var removeMemberView = {
let view = UIView()
view.backgroundColor = .clear
view.isHidden = true
let titleLab = UILabel()
titleLab.text = "移除成员"
titleLab.textColor = ThemeManager.shared.color.titleAuxColor
titleLab.font = .systemFont(ofSize: 12, weight: .medium)
view.addSubview(titleLab)
titleLab.layoutChain
.left(15)
.width(50)
.top(20)
let icon = UIImageView(image: UIImage(named: "Group/arrow"))
view.addSubview(icon)
icon.layoutChain
.right(15)
.width(14)
.height(14)
.centerY()
let line = UIView()
line.backgroundColor = ThemeManager.shared.color.lineColor
view.addSubview(line)
line.layoutChain
.bottom()
.height(0.5)
.edgesHorzontal(15)
return view
}()
//
lazy var dismissGroupView = {
let view = UIView()
view.backgroundColor = .clear
view.isHidden = true
let titleLab = UILabel()
titleLab.text = "解散圈子"
titleLab.textColor = ThemeManager.shared.color.titleAuxColor
titleLab.font = .systemFont(ofSize: 12, weight: .medium)
view.addSubview(titleLab)
titleLab.layoutChain
.left(15)
.width(50)
.top(20)
let icon = UIImageView(image: UIImage(named: "Group/arrow"))
view.addSubview(icon)
icon.layoutChain
.right(15)
.width(14)
.height(14)
.centerY()
let line = UIView()
line.backgroundColor = ThemeManager.shared.color.lineColor
view.addSubview(line)
line.layoutChain
.bottom()
.height(0.5)
.edgesHorzontal(15)
return view
}()
// 退
lazy var leaveGroupView = {
let view = UIView()
view.backgroundColor = .clear
view.isHidden = true
let titleLab = UILabel()
titleLab.text = "退出圈子"
titleLab.textColor = ThemeManager.shared.color.titleAuxColor
titleLab.font = .systemFont(ofSize: 12, weight: .medium)
view.addSubview(titleLab)
titleLab.layoutChain
.left(15)
.width(50)
.top(20)
let icon = UIImageView(image: UIImage(named: "Group/arrow"))
view.addSubview(icon)
icon.layoutChain
.right(15)
.width(14)
.height(14)
.centerY()
let line = UIView()
line.backgroundColor = ThemeManager.shared.color.lineColor
view.addSubview(line)
line.layoutChain
.bottom()
.height(0.5)
.edgesHorzontal(15)
return view
}()
override init(frame: CGRect) {
super.init(frame: .zero)
backgroundColor = .white
setupUI()
setupRx()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,19 @@
//
// GroupSettingViewModel.swift
// QuickLocation
//
// Created by on 2026/6/9.
//
import Foundation
struct GroupSettingViewModel {
let groupId: String
var groupModel: GroupInfoModel?
init(groupId: String) {
self.groupId = groupId
}
}

View File

@ -0,0 +1,303 @@
//
// GroupTagListView.swift
// QuickLocation
//
// Created by on 2026/6/9.
//
import UIKit
import RxSwift
import RxCocoa
class GroupTagListView: UIView {
private static let shared = GroupTagListView(frame: CGRect(origin: .zero, size: kScreenSize))
var disposeBag = DisposeBag()
private let tagList = ["私密", "聚会", "运动", "美食",
"旅行", "学习", "自驾", "游戏"]
private var selectedTagList: [String] = [] {
didSet {
tableView.reloadData()
}
}
///
private var completion: (([String]?) -> Void)?
@objc func tap() {
completion?(nil)
}
private lazy var bgView: UIView = {
let view = UIView()
view.backgroundColor = .black.withAlphaComponent(0.5)
view.clipsToBounds = true
return view
}()
lazy var infoView: UIView = {
let view = UIView()
view.backgroundColor = .white
let label = UILabel()
label.text = "圈子标签"
label.textColor = .black
label.font = .systemFont(ofSize: 16, weight: .medium)
view.addSubview(label)
label.layoutChain
.top(15)
.left(20)
view.addSubview(tableView)
view.addSubview(cancelBtn)
view.addSubview(confirmBtn)
cancelBtn.layoutChain
.left(43)
.bottom(kSafeBottomMargin + 24)
.height(40)
confirmBtn.layoutChain
.leftToRightOfView(cancelBtn, offset: 13)
.right(43)
.bottomToView(cancelBtn)
.heightToView(cancelBtn)
.widthToView(cancelBtn)
tableView.layoutChain
.topToBottomOfView(label, offset: 5)
.edgesHorzontal()
.bottomToTopOfView(cancelBtn, offset: -15)
return view
}()
lazy var tableView: UITableView = {
let tableView = UITableView(frame: .zero, style: .plain)
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.estimatedRowHeight = 38
tableView.showsVerticalScrollIndicator = false
tableView.bounces = false
tableView.isScrollEnabled = false
tableView.register(TagTextCell.self)
tableView.dataSource = self
tableView.delegate = self
return tableView
}()
lazy var confirmBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setTitle("确定", for: .normal)
btn.setTitleColor(.black, for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
btn.setBackgroundImage(UIImage(named: "Common/gradient_bg"), for: .normal)
btn.cornerRadius = 20
btn.rx.tap.subscribe(onNext: { _ in
if let completion = self.completion {
completion(self.selectedTagList)
}
}).disposed(by: disposeBag)
return btn
}()
lazy var cancelBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setTitle("取消", for: .normal)
btn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
btn.backgroundColor = .white
btn.borderWidth = 1
btn.borderColor = UIColor(hexStr: "#16B3FF")
btn.cornerRadius = 20
btn.rx.tap.subscribe(onNext: { _ in
GroupTagListView.dismiss()
}).disposed(by: disposeBag)
return btn
}()
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
addSubview(bgView)
bgView.addSubview(infoView)
infoView.addSubview(tableView)
infoView.addSubview(confirmBtn)
infoView.addSubview(cancelBtn)
bgView.layoutChain.edges()
let tap = UITapGestureRecognizer(target: self, action: #selector(tap))
tap.delegate = self
addGestureRecognizer(tap)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
infoView.setNeedsLayout()
infoView.layoutIfNeeded()
infoView.setCornerRadius(corners: [.topLeft, .topRight], withCornerRadii: CGSize(width: 20, height: 20))
}
}
// MARK: - Public
extension GroupTagListView {
///
/// - Parameters:
/// - start:
static func show(selectedTagList: [String],
completion: @escaping (([String]?) -> Void)) {
guard let superView = kKeyWindow else {
return
}
if GroupTagListView.shared.superview != nil {
GroupTagListView.shared.removeFromSuperview()
GroupTagListView.shared.bgView.frame = .zero
}
GroupTagListView.shared.selectedTagList = selectedTagList
GroupTagListView.shared.bgView.alpha = 1
superView.addSubview(GroupTagListView.shared)
superView.bringSubviewToFront(GroupTagListView.shared)
let viewHeight = kSafeBottomMargin + 24 + 40 + 40 * 8 + 40
GroupTagListView.shared.infoView.frame = CGRect(x: 0, y: kScreenHeight, width: kScreenWidth, height: viewHeight)
GroupTagListView.shared.infoView.alpha = 0
GroupTagListView.shared.completion = { tagList in
completion(tagList)
GroupTagListView.dismiss()
}
UIView.animate(withDuration: 0.25) {
GroupTagListView.shared.infoView.alpha = 1
GroupTagListView.shared.infoView.frame = CGRect(x: 0, y: kScreenHeight - viewHeight, width: kScreenWidth, height: viewHeight)
}
}
///
static func dismiss() {
guard GroupTagListView.shared.superview != nil else { return }
let viewHeight = kSafeBottomMargin + 24 + 40 + 40 * 8 + 40
UIView.animate(withDuration: 0.15) {
GroupTagListView.shared.infoView.alpha = 0
GroupTagListView.shared.infoView.frame = CGRect(x: 0, y: kScreenHeight, width: kScreenWidth, height: viewHeight)
}
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseIn]) {
GroupTagListView.shared.bgView.alpha = 0
} completion: { _ in
GroupTagListView.shared.removeFromSuperview()
GroupTagListView.shared.infoView.frame = .zero
}
}
}
// MARK: - UIGestureRecognizerDelegate
extension GroupTagListView: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
if let view = touch.view, !(view == self || view == bgView) {
return false
}
return true
}
}
// MARK: - UITableViewDataSource & UITableViewDelegate
extension GroupTagListView: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
tagList.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell: TagTextCell = tableView.dequeueReusableCell(for: indexPath)
let tagText = tagList[indexPath.row]
cell.configure(text: tagText, isSelected: selectedTagList.contains(tagText))
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let tagText = tagList[indexPath.row]
if let idx = selectedTagList.firstIndex(of: tagText) {
selectedTagList.remove(at: idx)
} else {
selectedTagList.append(tagText)
}
}
}
// MARK: - TagTextCell
class TagTextCell: UITableViewCell {
func configure(text: String, isSelected: Bool) {
titleLab.text = text
titleLab.textColor = isSelected ? UIColor(hexStr: "#16B3FF") : .black
checkBoxBtn.isSelected = isSelected
}
override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
setupSubviews()
}
private func setupSubviews() {
contentView.addSubview(titleLab)
contentView.addSubview(checkBoxBtn)
checkBoxBtn.layoutChain
.centerY()
.right(25)
.width(24)
.heightToWidth(1.0)
titleLab.layoutChain
.edgesVertical(10)
.left(20)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Configure the view for the selected state
}
lazy var titleLab: UILabel = {
let label = UILabel()
label.textColor = .black
label.font = .systemFont(ofSize: 15, weight: .medium)
return label
}()
lazy var checkBoxBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setBackgroundImage(UIImage(named: "Login/checkbox"), for: .normal)
btn.setBackgroundImage(UIImage(named: "Login/selected"), for: .selected)
btn.isUserInteractionEnabled = false
return btn
}()
}

View File

@ -0,0 +1,241 @@
//
// TextInputViewController.swift
// QuickLocation
//
// Created by on 2026/6/9.
//
import UIKit
import RxSwift
import RxCocoa
///
///
/// let vc = TextInputViewController(title: "", maxLength: 20) { text in
/// print(": \(text)")
/// }
/// present(vc, animated: true)
final class TextInputViewController: UIViewController {
private let titleText: String
private let maxLength: Int
private let confirmAction: ((String) -> Void)?
private let disposeBag = DisposeBag()
private let textRelay = BehaviorRelay<String>(value: "")
// MARK: - Init
/// - Parameters:
/// - title:
/// - maxLength: 0
/// - initialText:
/// - confirmAction:
init(title: String,
maxLength: Int = 0,
initialText: String = "",
confirmAction: ((String) -> Void)? = nil) {
self.titleText = title
self.maxLength = maxLength
self.confirmAction = confirmAction
self.textRelay.accept(initialText)
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
modalTransitionStyle = .coverVertical
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
setupUI()
setupBinding()
navTitleLabel.text = titleText
textView.becomeFirstResponder()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
}
// MARK: - UI
private func setupUI() {
view.addSubview(navBgView)
view.addSubview(navBarView)
navBarView.addSubview(navTitleLabel)
navBarView.addSubview(backBtn)
view.addSubview(inputTextView)
inputTextView.addSubview(textView)
view.addSubview(countLabel)
view.addSubview(confirmBtn)
navBgView.layoutChain
.edges(excludingEdge: .bottom)
.heightToWidth(160/375)
navBarView.layoutChain
.edges(excludingEdge: .bottom)
.height(kNaviHeight)
navTitleLabel.layoutChain
.top(kStatusBarHeight + 12)
.centerY(backBtn)
.centerX()
backBtn.layoutChain
.centerY(navTitleLabel)
.left(15)
.width(24)
.height(24)
inputTextView.layoutChain
.topToBottomOfView(navBarView, offset: 15)
.edgesHorzontal(15)
//
textView.layoutChain
.edgesVertical(5)
.edgesHorzontal(10)
countLabel.layoutChain
.topToBottomOfView(inputTextView, offset: 5)
.rightToView(textView)
confirmBtn.layoutChain
.topToBottomOfView(inputTextView, offset: 50)
.edgesHorzontal(15).height(50)
}
// MARK: - Binding
private func setupBinding() {
//
Observable.merge(
textView.rx.didChange.asObservable(),
textView.rx.text.map { _ in () },
textView.rx.methodInvoked(#selector(UITextView.paste(_:))).map { _ in () }
)
.throttle(.milliseconds(100), scheduler: MainScheduler.instance)
.subscribe(onNext: { [weak self] in
guard let self = self else { return }
if self.textView.text.last == "\n" {
self.textView.text = String(self.textView.text.dropLast())
self.textView.resignFirstResponder()
return
}
let count = self.textView.text.count
if count > self.maxLength {
self.textView.text = String(self.textView.text.prefix(self.maxLength))
self.textView.selectedRange = NSRange(location: self.maxLength, length: 0)
return
}
self.countLabel.text = "\(count)/\(self.maxLength)"
})
.disposed(by: disposeBag)
textRelay.asObservable()
.bind(to: textView.rx.text)
.disposed(by: disposeBag)
//
textView.rx.text.orEmpty.map { text in
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return !trimmed.isEmpty
}
.bind(to: confirmBtn.rx.isEnabled)
.disposed(by: disposeBag)
confirmBtn.rx.tap.subscribe(onNext: { [weak self] in
guard let self = self, let text = self.textView.text else { return }
self.confirmAction?(text)
self.dismiss(animated: true)
})
.disposed(by: disposeBag)
//
backBtn.rx.tap
.subscribe(onNext: { [weak self] _ in
self?.dismiss(animated: true)
})
.disposed(by: disposeBag)
}
// MARK: - Views
lazy var navBgView: UIImageView = {
let iv = UIImageView()
iv.image = UIImage(named: "Common/navBar_bg_2")
iv.contentMode = .scaleAspectFill
return iv
}()
lazy var navBarView: UIView = {
let view = UIView()
view.backgroundColor = .clear
return view
}()
lazy var navTitleLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 18, weight: .medium)
label.textColor = ThemeManager.shared.color.titleAuxColor
label.textAlignment = .center
return label
}()
lazy var backBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "Common/back"), for: .normal)
btn.extendEdgeInsets = UIEdgeInsets(top: 54, left: 15, bottom: 100, right: 100)
return btn
}()
lazy var inputTextView: UIView = {
let view = UIView()
view.backgroundColor = .white
view.cornerRadius = 4
view.borderWidth = 0.5
view.borderColor = ThemeManager.shared.color.lineColor
return view
}()
lazy var textView: UITextView = {
let tv = UITextView()
tv.font = .systemFont(ofSize: 15)
tv.textColor = ThemeManager.shared.color.titleAuxColor
tv.backgroundColor = .clear
tv.showsVerticalScrollIndicator = false
tv.isScrollEnabled = false
tv.bounces = false
tv.returnKeyType = .done
return tv
}()
private lazy var countLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 13)
label.textColor = UIColor(hexStr: "#999999")
label.text = maxLength > 0 ? "0/\(maxLength)" : ""
return label
}()
private lazy var confirmBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setTitle("确定", for: .normal)
btn.setTitleColor(UIColor(hexStr: "#0F2846"), for: .normal)
btn.setBackgroundImage(UIImage(named: "Common/gradient_bg"), for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
btn.cornerRadius = 25
btn.isEnabled = false
return btn
}()
}

View File

@ -45,4 +45,54 @@ struct GroupService {
.map(GroupInfoResponse.self)
.asObservable()
}
///
/// - Parameters:
/// - requestDatagroup_key group_name
static func editName(requestData: [String: Any]) -> Observable<GroupInfoResponse> {
let api = GroupAPI.operate(opType: "changename", requestData: requestData).multiTarget
return APIProvider.request(token: api)
.map(GroupInfoResponse.self)
.asObservable()
}
///
/// - Parameters:
/// - requestDatagroup_key icon_index
static func changeIcon(requestData: [String: Any]) -> Observable<GroupInfoResponse> {
let api = GroupAPI.operate(opType: "changeicon", requestData: requestData).multiTarget
return APIProvider.request(token: api)
.map(GroupInfoResponse.self)
.asObservable()
}
///
/// - Parameters:
/// - requestDatagroup_key description
static func editDesc(requestData: [String: Any]) -> Observable<GroupInfoResponse> {
let api = GroupAPI.operate(opType: "changedescription", requestData: requestData).multiTarget
return APIProvider.request(token: api)
.map(GroupInfoResponse.self)
.asObservable()
}
///
/// - Parameters:
/// - requestDatagroup_key review
static func changeReview(requestData: [String: Any]) -> Observable<GroupInfoResponse> {
let api = GroupAPI.operate(opType: "changereview", requestData: requestData).multiTarget
return APIProvider.request(token: api)
.map(GroupInfoResponse.self)
.asObservable()
}
///
/// - Parameters:
/// - requestDatagroup_key labels
static func editLabels(requestData: [String: Any]) -> Observable<GroupInfoResponse> {
let api = GroupAPI.operate(opType: "changelabels", requestData: requestData).multiTarget
return APIProvider.request(token: api)
.map(GroupInfoResponse.self)
.asObservable()
}
}

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24765" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24743"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Launch/logo" translatesAutoresizingMaskIntoConstraints="NO" id="hzJ-1y-aVD">
<rect key="frame" x="52.666666666666657" y="282" width="288" height="288"/>
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Launch/slogan" translatesAutoresizingMaskIntoConstraints="NO" id="Dot-oD-XM4">
<rect key="frame" x="96.666666666666686" y="654" width="200" height="80"/>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
<color key="backgroundColor" red="0.8784313725490196" green="0.94901960784313721" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="hzJ-1y-aVD" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="9NL-SO-M2t"/>
<constraint firstItem="Dot-oD-XM4" firstAttribute="bottom" secondItem="6Tk-OE-BBY" secondAttribute="bottom" constant="-50" id="LUS-mh-ZXm"/>
<constraint firstItem="Dot-oD-XM4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="UgU-yT-gN4"/>
<constraint firstItem="hzJ-1y-aVD" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="a8U-VN-jeR"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="Launch/logo" width="288" height="288"/>
<image name="Launch/slogan" width="200" height="80"/>
</resources>
</document>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="ViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>