BreedPickerViewController.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. //
  2. // BreedPickerViewController.swift
  3. // VenusKitto
  4. //
  5. // Created by Neoa on 2025/8/22.
  6. //
  7. import Foundation
  8. import UIKit
  9. // MARK: - BreedPickerViewController (选择品种)
  10. final class BreedPickerViewController: UIViewController, UITextFieldDelegate {
  11. struct Breed { let id: String; let categoryId: String; let name: String; let isHot: String }
  12. private let categoryId: Int
  13. private let token: String?
  14. var onSelect: ((String, String) -> Void)?
  15. // UI
  16. private let scrollView = UIScrollView()
  17. private let content = UIStackView()
  18. private let searchContainer = UIView()
  19. private let searchIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
  20. private let searchField = UITextField()
  21. private let searchBtn = UIButton(type: .system)
  22. init(categoryId: Int, token: String?) {
  23. self.categoryId = categoryId
  24. self.token = token
  25. super.init(nibName: nil, bundle: nil)
  26. title = "选择品种"
  27. }
  28. required init?(coder: NSCoder) { fatalError() }
  29. override func viewDidLoad() {
  30. super.viewDidLoad()
  31. view.backgroundColor = .white
  32. buildUI()
  33. loadBreeds(keyword: "")
  34. }
  35. private func buildUI() {
  36. // 搜索条
  37. searchContainer.backgroundColor = UIColor(hex: "#F7F7F7")
  38. searchContainer.layer.cornerRadius = 22
  39. searchContainer.layer.borderWidth = 1
  40. searchContainer.layer.borderColor = UIColor(hex: "#E6E6E6").cgColor
  41. searchIcon.tintColor = UIColor(hex: "#B0B0B0")
  42. searchIcon.contentMode = .scaleAspectFit
  43. searchField.placeholder = "搜索"
  44. searchField.font = .systemFont(ofSize: 14)
  45. searchField.delegate = self
  46. searchField.returnKeyType = .search
  47. searchBtn.setTitle("搜索", for: .normal)
  48. searchBtn.setTitleColor(.black, for: .normal)
  49. searchBtn.backgroundColor = UIColor(hex: "#FFE059")
  50. searchBtn.layer.cornerRadius = 18
  51. searchBtn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
  52. searchBtn.addTarget(self, action: #selector(tapSearch), for: .touchUpInside)
  53. view.addSubview(searchContainer)
  54. [searchIcon, searchField, searchBtn].forEach { searchContainer.addSubview($0); $0.translatesAutoresizingMaskIntoConstraints = false }
  55. searchContainer.translatesAutoresizingMaskIntoConstraints = false
  56. // 列表
  57. scrollView.showsVerticalScrollIndicator = false
  58. view.addSubview(scrollView)
  59. scrollView.translatesAutoresizingMaskIntoConstraints = false
  60. content.axis = .vertical
  61. content.spacing = 16
  62. content.alignment = .fill
  63. scrollView.addSubview(content)
  64. content.translatesAutoresizingMaskIntoConstraints = false
  65. NSLayoutConstraint.activate([
  66. searchContainer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
  67. searchContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
  68. searchContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
  69. searchContainer.heightAnchor.constraint(equalToConstant: 44),
  70. searchIcon.leadingAnchor.constraint(equalTo: searchContainer.leadingAnchor, constant: 12),
  71. searchIcon.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
  72. searchIcon.widthAnchor.constraint(equalToConstant: 18),
  73. searchIcon.heightAnchor.constraint(equalToConstant: 18),
  74. searchBtn.trailingAnchor.constraint(equalTo: searchContainer.trailingAnchor, constant: -8),
  75. searchBtn.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
  76. searchBtn.widthAnchor.constraint(equalToConstant: 66),
  77. searchBtn.heightAnchor.constraint(equalToConstant: 36),
  78. searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
  79. searchField.trailingAnchor.constraint(equalTo: searchBtn.leadingAnchor, constant: -8),
  80. searchField.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
  81. scrollView.topAnchor.constraint(equalTo: searchContainer.bottomAnchor, constant: 12),
  82. scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  83. scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  84. scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  85. content.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 8),
  86. content.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
  87. content.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
  88. content.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24),
  89. content.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
  90. ])
  91. }
  92. @objc private func tapSearch() { loadBreeds(keyword: searchField.text ?? "") }
  93. func textFieldShouldReturn(_ textField: UITextField) -> Bool { tapSearch(); return true }
  94. // MARK: - Networking
  95. private func loadBreeds(keyword: String) {
  96. // 清空旧内容
  97. content.arrangedSubviews.forEach { $0.removeFromSuperview() }
  98. var comps = URLComponents(string: "\(baseURL)/petRecordBreed/queryBreed")!
  99. comps.queryItems = [
  100. URLQueryItem(name: "categoryId", value: String(categoryId)),
  101. URLQueryItem(name: "keyword", value: keyword)
  102. ]
  103. guard let url = comps.url else { return }
  104. var req = URLRequest(url: url)
  105. req.httpMethod = "GET"
  106. // Optional: tell server we expect JSON
  107. req.setValue("application/json", forHTTPHeaderField: "Accept")
  108. if let token = token, !token.isEmpty {
  109. req.setValue(token, forHTTPHeaderField: "Authorization")
  110. }
  111. let loading = makeLoading()
  112. content.addArrangedSubview(loading)
  113. URLSession.shared.dataTask(with: req) { [weak self] data, resp, error in
  114. DispatchQueue.main.async {
  115. loading.removeFromSuperview()
  116. }
  117. guard let self = self else { return }
  118. if let error = error {
  119. DispatchQueue.main.async { self.showError(error.localizedDescription) }
  120. return
  121. }
  122. guard let data = data,
  123. let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
  124. DispatchQueue.main.async { self.showError("无效的响应") }
  125. return
  126. }
  127. let codeAny = obj["code"]
  128. let isOK: Bool
  129. if let c = codeAny as? Int { isOK = (c == 200) }
  130. else if let s = codeAny as? String { isOK = (s == "200") }
  131. else { isOK = false }
  132. guard isOK, let arr = obj["data"] as? [[String: Any]] else {
  133. DispatchQueue.main.async { self.showError(obj["msg"] as? String ?? "请求失败") }
  134. return
  135. }
  136. let breeds = arr.compactMap { d -> Breed? in
  137. guard let id = d["id"] as? String,
  138. let cid = d["categoryId"] as? String,
  139. let name = d["name"] as? String,
  140. let hot = d["isHot"] as? String else { return nil }
  141. return Breed(id: id, categoryId: cid, name: name, isHot: hot)
  142. }
  143. self.render(breeds: breeds)
  144. }.resume()
  145. }
  146. private func render(breeds: [Breed]) {
  147. DispatchQueue.main.async {
  148. self.content.arrangedSubviews.forEach { $0.removeFromSuperview() }
  149. // 热门
  150. let hot = breeds.filter { $0.isHot == "1" }
  151. if !hot.isEmpty {
  152. self.content.addArrangedSubview(self.makeSectionTitle("热门品种"))
  153. self.content.addArrangedSubview(self.makeChipsGrid(hot))
  154. }
  155. // 其他分组(按拼音首字母)
  156. let others = breeds.filter { $0.isHot != "1" }
  157. let grouped = Dictionary(grouping: others) { self.initial(of: $0.name) }
  158. let keys = grouped.keys.sorted()
  159. for k in keys {
  160. self.content.addArrangedSubview(self.makeLetterTitle(k))
  161. self.content.addArrangedSubview(self.makeChipsGrid(grouped[k] ?? []))
  162. }
  163. }
  164. }
  165. private func initial(of name: String) -> String {
  166. var s = name as NSString
  167. let mutable = NSMutableString(string: s)
  168. CFStringTransform(mutable, nil, kCFStringTransformToLatin, false)
  169. CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false)
  170. let up = (mutable as String).uppercased()
  171. let ch = up.first ?? "#"
  172. if ch >= "A" && ch <= "Z" { return String(ch) } else { return "#" }
  173. }
  174. private func makeSectionTitle(_ text: String) -> UIView {
  175. let l = UILabel()
  176. l.text = text
  177. l.font = .systemFont(ofSize: 14, weight: .semibold)
  178. l.textColor = UIColor(hex: "#2B2B2B")
  179. return l
  180. }
  181. private func makeLetterTitle(_ letter: String) -> UIView {
  182. let l = UILabel()
  183. l.text = letter
  184. l.font = .systemFont(ofSize: 14, weight: .semibold)
  185. l.textColor = UIColor(hex: "#2B2B2B")
  186. l.translatesAutoresizingMaskIntoConstraints = false
  187. return l
  188. }
  189. private func makeChipsGrid(_ items: [Breed]) -> UIView {
  190. let wrap = UIStackView()
  191. wrap.axis = .vertical
  192. wrap.spacing = 12
  193. func makeRow() -> UIStackView {
  194. let r = UIStackView()
  195. r.axis = .horizontal
  196. r.alignment = .leading
  197. r.distribution = .fill
  198. r.spacing = 12
  199. return r
  200. }
  201. var row = makeRow()
  202. wrap.addArrangedSubview(row)
  203. // content width available (16 left & right padding)
  204. let availableWidth = view.bounds.width - 32
  205. let maxInRow = 4
  206. let spacing: CGFloat = 12
  207. var usedWidth: CGFloat = 0
  208. var countInRow = 0
  209. for b in items {
  210. let chip = BreedChip(title: b.name)
  211. let chipWidth = chip.estimatedWidth()
  212. // If the row is full or it doesn't fit, start a new row
  213. let needWrap = (countInRow >= maxInRow) || (countInRow > 0 && usedWidth + spacing + chipWidth > availableWidth)
  214. if needWrap {
  215. row = makeRow()
  216. wrap.addArrangedSubview(row)
  217. usedWidth = 0
  218. countInRow = 0
  219. }
  220. row.addArrangedSubview(chip)
  221. usedWidth += (countInRow == 0 ? 0 : spacing) + chipWidth
  222. countInRow += 1
  223. // Tap action
  224. chip.onTap = { [weak self] in
  225. self?.onSelect?(b.id, b.name)
  226. }
  227. }
  228. return wrap
  229. }
  230. private func makeLoading() -> UIView {
  231. let l = UIActivityIndicatorView(style: .medium)
  232. l.startAnimating()
  233. return l
  234. }
  235. private func showError(_ msg: String) {
  236. let lb = UILabel()
  237. lb.text = msg
  238. lb.textAlignment = .center
  239. lb.textColor = UIColor(hex: "#A8A8A8")
  240. content.addArrangedSubview(lb)
  241. }
  242. }
  243. final class BreedChip: UIView {
  244. private let button = UIButton(type: .system)
  245. var onTap: (() -> Void)?
  246. init(title: String) {
  247. super.init(frame: .zero)
  248. button.setTitle(title, for: .normal)
  249. button.setTitleColor(UIColor(hex: "#5B5B5B"), for: .normal)
  250. button.titleLabel?.font = .systemFont(ofSize: 13)
  251. button.backgroundColor = UIColor(hex: "#EFECEA")
  252. button.layer.cornerRadius = 18
  253. button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
  254. // 让 chip 保持自身宽度,不被横向拉伸
  255. setContentHuggingPriority(.required, for: .horizontal)
  256. setContentCompressionResistancePriority(.required, for: .horizontal)
  257. button.setContentHuggingPriority(.required, for: .horizontal)
  258. button.setContentCompressionResistancePriority(.required, for: .horizontal)
  259. button.titleLabel?.lineBreakMode = .byTruncatingTail
  260. button.addTarget(self, action: #selector(tap), for: .touchUpInside)
  261. addSubview(button)
  262. button.translatesAutoresizingMaskIntoConstraints = false
  263. NSLayoutConstraint.activate([
  264. button.topAnchor.constraint(equalTo: topAnchor),
  265. button.bottomAnchor.constraint(equalTo: bottomAnchor),
  266. button.leadingAnchor.constraint(equalTo: leadingAnchor),
  267. button.trailingAnchor.constraint(equalTo: trailingAnchor)
  268. ])
  269. }
  270. required init?(coder: NSCoder) { fatalError() }
  271. @objc private func tap() { onTap?() }
  272. func estimatedWidth() -> CGFloat {
  273. let target = CGSize(width: CGFloat.greatestFiniteMagnitude, height: 36)
  274. let size = button.sizeThatFits(target)
  275. return size.width
  276. }
  277. }