| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 |
- //
- // BreedPickerViewController.swift
- // VenusKitto
- //
- // Created by Neoa on 2025/8/22.
- //
- import Foundation
- import UIKit
- // MARK: - BreedPickerViewController (选择品种)
- final class BreedPickerViewController: UIViewController, UITextFieldDelegate {
- struct Breed { let id: String; let categoryId: String; let name: String; let isHot: String }
- private let categoryId: Int
- private let token: String?
- var onSelect: ((String, String) -> Void)?
- // UI
- private let scrollView = UIScrollView()
- private let content = UIStackView()
- private let searchContainer = UIView()
- private let searchIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
- private let searchField = UITextField()
- private let searchBtn = UIButton(type: .system)
- init(categoryId: Int, token: String?) {
- self.categoryId = categoryId
- self.token = token
- super.init(nibName: nil, bundle: nil)
- title = "选择品种"
- }
- required init?(coder: NSCoder) { fatalError() }
- override func viewDidLoad() {
- super.viewDidLoad()
- view.backgroundColor = .white
- buildUI()
- loadBreeds(keyword: "")
- }
- private func buildUI() {
- // 搜索条
- searchContainer.backgroundColor = UIColor(hex: "#F7F7F7")
- searchContainer.layer.cornerRadius = 22
- searchContainer.layer.borderWidth = 1
- searchContainer.layer.borderColor = UIColor(hex: "#E6E6E6").cgColor
- searchIcon.tintColor = UIColor(hex: "#B0B0B0")
- searchIcon.contentMode = .scaleAspectFit
- searchField.placeholder = "搜索"
- searchField.font = .systemFont(ofSize: 14)
- searchField.delegate = self
- searchField.returnKeyType = .search
- searchBtn.setTitle("搜索", for: .normal)
- searchBtn.setTitleColor(.black, for: .normal)
- searchBtn.backgroundColor = UIColor(hex: "#FFE059")
- searchBtn.layer.cornerRadius = 18
- searchBtn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
- searchBtn.addTarget(self, action: #selector(tapSearch), for: .touchUpInside)
- view.addSubview(searchContainer)
- [searchIcon, searchField, searchBtn].forEach { searchContainer.addSubview($0); $0.translatesAutoresizingMaskIntoConstraints = false }
- searchContainer.translatesAutoresizingMaskIntoConstraints = false
- // 列表
- scrollView.showsVerticalScrollIndicator = false
- view.addSubview(scrollView)
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- content.axis = .vertical
- content.spacing = 16
- content.alignment = .fill
- scrollView.addSubview(content)
- content.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- searchContainer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
- searchContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
- searchContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
- searchContainer.heightAnchor.constraint(equalToConstant: 44),
- searchIcon.leadingAnchor.constraint(equalTo: searchContainer.leadingAnchor, constant: 12),
- searchIcon.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
- searchIcon.widthAnchor.constraint(equalToConstant: 18),
- searchIcon.heightAnchor.constraint(equalToConstant: 18),
- searchBtn.trailingAnchor.constraint(equalTo: searchContainer.trailingAnchor, constant: -8),
- searchBtn.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
- searchBtn.widthAnchor.constraint(equalToConstant: 66),
- searchBtn.heightAnchor.constraint(equalToConstant: 36),
- searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
- searchField.trailingAnchor.constraint(equalTo: searchBtn.leadingAnchor, constant: -8),
- searchField.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
- scrollView.topAnchor.constraint(equalTo: searchContainer.bottomAnchor, constant: 12),
- scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- content.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 8),
- content.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
- content.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
- content.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24),
- content.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
- ])
- }
- @objc private func tapSearch() { loadBreeds(keyword: searchField.text ?? "") }
- func textFieldShouldReturn(_ textField: UITextField) -> Bool { tapSearch(); return true }
- // MARK: - Networking
- private func loadBreeds(keyword: String) {
- // 清空旧内容
- content.arrangedSubviews.forEach { $0.removeFromSuperview() }
- var comps = URLComponents(string: "\(baseURL)/petRecordBreed/queryBreed")!
- comps.queryItems = [
- URLQueryItem(name: "categoryId", value: String(categoryId)),
- URLQueryItem(name: "keyword", value: keyword)
- ]
- guard let url = comps.url else { return }
- var req = URLRequest(url: url)
- req.httpMethod = "GET"
- // Optional: tell server we expect JSON
- req.setValue("application/json", forHTTPHeaderField: "Accept")
- if let token = token, !token.isEmpty {
- req.setValue(token, forHTTPHeaderField: "Authorization")
- }
- let loading = makeLoading()
- content.addArrangedSubview(loading)
- URLSession.shared.dataTask(with: req) { [weak self] data, resp, error in
- DispatchQueue.main.async {
- loading.removeFromSuperview()
- }
- guard let self = self else { return }
- if let error = error {
- DispatchQueue.main.async { self.showError(error.localizedDescription) }
- return
- }
- guard let data = data,
- let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
- DispatchQueue.main.async { self.showError("无效的响应") }
- return
- }
- let codeAny = obj["code"]
- let isOK: Bool
- if let c = codeAny as? Int { isOK = (c == 200) }
- else if let s = codeAny as? String { isOK = (s == "200") }
- else { isOK = false }
- guard isOK, let arr = obj["data"] as? [[String: Any]] else {
- DispatchQueue.main.async { self.showError(obj["msg"] as? String ?? "请求失败") }
- return
- }
- let breeds = arr.compactMap { d -> Breed? in
- guard let id = d["id"] as? String,
- let cid = d["categoryId"] as? String,
- let name = d["name"] as? String,
- let hot = d["isHot"] as? String else { return nil }
- return Breed(id: id, categoryId: cid, name: name, isHot: hot)
- }
- self.render(breeds: breeds)
- }.resume()
- }
- private func render(breeds: [Breed]) {
- DispatchQueue.main.async {
- self.content.arrangedSubviews.forEach { $0.removeFromSuperview() }
- // 热门
- let hot = breeds.filter { $0.isHot == "1" }
- if !hot.isEmpty {
- self.content.addArrangedSubview(self.makeSectionTitle("热门品种"))
- self.content.addArrangedSubview(self.makeChipsGrid(hot))
- }
- // 其他分组(按拼音首字母)
- let others = breeds.filter { $0.isHot != "1" }
- let grouped = Dictionary(grouping: others) { self.initial(of: $0.name) }
- let keys = grouped.keys.sorted()
- for k in keys {
- self.content.addArrangedSubview(self.makeLetterTitle(k))
- self.content.addArrangedSubview(self.makeChipsGrid(grouped[k] ?? []))
- }
- }
- }
- private func initial(of name: String) -> String {
- var s = name as NSString
- let mutable = NSMutableString(string: s)
- CFStringTransform(mutable, nil, kCFStringTransformToLatin, false)
- CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false)
- let up = (mutable as String).uppercased()
- let ch = up.first ?? "#"
- if ch >= "A" && ch <= "Z" { return String(ch) } else { return "#" }
- }
- private func makeSectionTitle(_ text: String) -> UIView {
- let l = UILabel()
- l.text = text
- l.font = .systemFont(ofSize: 14, weight: .semibold)
- l.textColor = UIColor(hex: "#2B2B2B")
- return l
- }
- private func makeLetterTitle(_ letter: String) -> UIView {
- let l = UILabel()
- l.text = letter
- l.font = .systemFont(ofSize: 14, weight: .semibold)
- l.textColor = UIColor(hex: "#2B2B2B")
- l.translatesAutoresizingMaskIntoConstraints = false
- return l
- }
- private func makeChipsGrid(_ items: [Breed]) -> UIView {
- let wrap = UIStackView()
- wrap.axis = .vertical
- wrap.spacing = 12
- func makeRow() -> UIStackView {
- let r = UIStackView()
- r.axis = .horizontal
- r.alignment = .leading
- r.distribution = .fill
- r.spacing = 12
- return r
- }
- var row = makeRow()
- wrap.addArrangedSubview(row)
- // content width available (16 left & right padding)
- let availableWidth = view.bounds.width - 32
- let maxInRow = 4
- let spacing: CGFloat = 12
- var usedWidth: CGFloat = 0
- var countInRow = 0
- for b in items {
- let chip = BreedChip(title: b.name)
- let chipWidth = chip.estimatedWidth()
- // If the row is full or it doesn't fit, start a new row
- let needWrap = (countInRow >= maxInRow) || (countInRow > 0 && usedWidth + spacing + chipWidth > availableWidth)
- if needWrap {
- row = makeRow()
- wrap.addArrangedSubview(row)
- usedWidth = 0
- countInRow = 0
- }
- row.addArrangedSubview(chip)
- usedWidth += (countInRow == 0 ? 0 : spacing) + chipWidth
- countInRow += 1
- // Tap action
- chip.onTap = { [weak self] in
- self?.onSelect?(b.id, b.name)
- }
- }
- return wrap
- }
- private func makeLoading() -> UIView {
- let l = UIActivityIndicatorView(style: .medium)
- l.startAnimating()
- return l
- }
- private func showError(_ msg: String) {
- let lb = UILabel()
- lb.text = msg
- lb.textAlignment = .center
- lb.textColor = UIColor(hex: "#A8A8A8")
- content.addArrangedSubview(lb)
- }
- }
- final class BreedChip: UIView {
- private let button = UIButton(type: .system)
- var onTap: (() -> Void)?
- init(title: String) {
- super.init(frame: .zero)
- button.setTitle(title, for: .normal)
- button.setTitleColor(UIColor(hex: "#5B5B5B"), for: .normal)
- button.titleLabel?.font = .systemFont(ofSize: 13)
- button.backgroundColor = UIColor(hex: "#EFECEA")
- button.layer.cornerRadius = 18
- button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
- // 让 chip 保持自身宽度,不被横向拉伸
- setContentHuggingPriority(.required, for: .horizontal)
- setContentCompressionResistancePriority(.required, for: .horizontal)
- button.setContentHuggingPriority(.required, for: .horizontal)
- button.setContentCompressionResistancePriority(.required, for: .horizontal)
- button.titleLabel?.lineBreakMode = .byTruncatingTail
- button.addTarget(self, action: #selector(tap), for: .touchUpInside)
- addSubview(button)
- button.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- button.topAnchor.constraint(equalTo: topAnchor),
- button.bottomAnchor.constraint(equalTo: bottomAnchor),
- button.leadingAnchor.constraint(equalTo: leadingAnchor),
- button.trailingAnchor.constraint(equalTo: trailingAnchor)
- ])
- }
- required init?(coder: NSCoder) { fatalError() }
- @objc private func tap() { onTap?() }
- func estimatedWidth() -> CGFloat {
- let target = CGSize(width: CGFloat.greatestFiniteMagnitude, height: 36)
- let size = button.sizeThatFits(target)
- return size.width
- }
- }
|