¥
立即购买

iOS界面元素技术文档生成器

12 浏览
1 试用
0 购买
Dec 5, 2025更新

本提示词专为iOS开发场景设计,能够根据用户提供的界面元素描述,生成专业、准确、结构化的技术文档。通过系统化的分析流程,确保输出的文档包含元素功能定义、技术实现方案、交互行为描述和最佳实践建议,帮助开发者快速理解和实现iOS界面组件。文档采用标准化的技术写作风格,避免主观表述,专注于提供可操作的技术指导。

元素概述

  • 类型:UIKit UIButton(登录按钮)
  • 视觉:系统字体字号 17,主色填充背景,圆角 12
  • 状态:
    • 启用:手机号+验证码校验通过
    • 加载:点击后显示内置菊花(Activity Indicator),禁用交互,2s 超时自动恢复
    • 禁用:校验失败或加载中
  • 交互:轻触回弹动画与轻震(UIImpactFeedbackStyle.light),长按无效
  • 无障碍:VoiceOver 读作“登录,按钮”,加载中提供状态提示
  • 主题与可访问性:支持浅/深色、动态字体(Dynamic Type)
  • 埋点:点击、成功、失败三事件,附加用户ID
  • 布局:高度 48;左右安全边距 16;与验证码输入框间距 12
  • 国际化文案键:auth_login

技术实现

以下为完整的参考实现,兼容 iOS 13+。在 iOS 15+ 上优先使用 UIButton.Configuration 实现活动指示器与状态样式;在 iOS 13–14 使用自定义子视图实现菊花。

1)资源与颜色约定

  • 颜色(建议在 Asset Catalog 中定义带浅/深色变体):
    • AppPrimary(主色,用于启用状态背景)
    • AppPrimaryDisabled(禁用状态背景,建议接近系统灰)
  • 国际化:
    • Localizable.strings
      • "auth_login" = "登录"; // zh-Hans
      • "auth_login" = "Login"; // en
  • 动态字体:基于 .headline 文本样式缩放系统字体 17pt

2)登录按钮类(含加载、超时、动画、无障碍、配置)

import UIKit

protocol AnalyticsLogging {
    func log(event: String, properties: [String: Any]?)
}

final class LoginButton: UIButton {

    // MARK: - Public API

    // 点击回调(由控制器注入具体登录逻辑)
    var onTap: (() -> Void)?

    // 2s 超时任务
    private var timeoutWorkItem: DispatchWorkItem?

    // 当前是否加载中
    private(set) var isLoading = false

    // 提供外部更新启用态的方法(结合表单校验结果)
    func updateEnabledState(canSubmit: Bool) {
        guard !isLoading else {
            // 加载中强制禁用
            isEnabled = false
            return
        }
        isEnabled = canSubmit
    }

    // 开始加载(默认2秒超时)
    func startLoading(timeout: TimeInterval = 2.0) {
        guard !isLoading else { return }
        isLoading = true
        isUserInteractionEnabled = false
        isEnabled = false
        applyLoadingAppearance(true)

        // VoiceOver 提示
        UIAccessibility.post(notification: .announcement, argument: NSLocalizedString("正在登录", comment: "正在登录"))

        // 超时恢复
        timeoutWorkItem?.cancel()
        let work = DispatchWorkItem { [weak self] in
            self?.stopLoading(restoreEnabledFromValidation: true)
        }
        timeoutWorkItem = work
        DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: work)
    }

    // 停止加载
    // restoreEnabledFromValidation: true 时,按钮启用态应由外部再次根据表单校验结果更新
    func stopLoading(restoreEnabledFromValidation: Bool = true) {
        timeoutWorkItem?.cancel()
        timeoutWorkItem = nil

        isLoading = false
        isUserInteractionEnabled = true
        applyLoadingAppearance(false)

        // 移除加载状态的辅助功能值
        accessibilityValue = nil

        // 启用态恢复交给外部(表单校验)更安全
        if !restoreEnabledFromValidation {
            isEnabled = true
        }
    }

    // MARK: - Init

    private let spinner: UIActivityIndicatorView = {
        let v = UIActivityIndicatorView(style: .medium)
        v.hidesWhenStopped = true
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }

    // MARK: - Setup

    private func setup() {
        isAccessibilityElement = true
        accessibilityTraits = [.button]
        accessibilityLabel = NSLocalizedString("auth_login", comment: "Login")

        layer.cornerRadius = 12
        layer.masksToBounds = true

        // 字体:系统 17,随 Dynamic Type 缩放
        let baseFont = UIFont.systemFont(ofSize: 17, weight: .regular)
        titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: baseFont)
        titleLabel?.adjustsFontForContentSizeCategory = true
        titleLabel?.lineBreakMode = .byTruncatingTail

        setTitle(NSLocalizedString("auth_login", comment: "Login"), for: .normal)

        if #available(iOS 15.0, *) {
            var cfg = UIButton.Configuration.filled()
            cfg.title = NSLocalizedString("auth_login", comment: "Login")
            cfg.baseForegroundColor = .white
            cfg.baseBackgroundColor = UIColor(named: "AppPrimary")
            cfg.cornerStyle = .fixed
            cfg.background.cornerRadius = 12
            configuration = cfg

            configurationUpdateHandler = { [weak self] btn in
                guard let self = self else { return }
                var c = btn.configuration!
                c.baseBackgroundColor = btn.isEnabled ? UIColor(named: "AppPrimary") : UIColor(named: "AppPrimaryDisabled")
                btn.configuration = c
            }
        } else {
            // iOS 13–14 样式
            backgroundColor = UIColor(named: "AppPrimary")
            setTitleColor(.white, for: .normal)
            addSubview(spinner)
            NSLayoutConstraint.activate([
                spinner.centerXAnchor.constraint(equalTo: centerXAnchor),
                spinner.centerYAnchor.constraint(equalTo: centerYAnchor)
            ])
        }

        // 交互
        addTarget(self, action: #selector(didTouchDown), for: .touchDown)
        addTarget(self, action: #selector(didTap), for: .touchUpInside)
        addTarget(self, action: #selector(cancelPress), for: [.touchDragExit, .touchCancel, .touchUpOutside])

        // 不添加任何长按手势;默认无长按动作
    }

    // MARK: - Loading appearance

    private func applyLoadingAppearance(_ loading: Bool) {
        if #available(iOS 15.0, *) {
            if var cfg = configuration {
                cfg.showsActivityIndicator = loading
                configuration = cfg
            }
        } else {
            if loading {
                titleLabel?.alpha = 0.0
                spinner.color = titleColor(for: .normal)
                spinner.startAnimating()
            } else {
                spinner.stopAnimating()
                titleLabel?.alpha = 1.0
            }
        }

        // 无障碍:loading 状态提示
        accessibilityValue = loading ? NSLocalizedString("加载中", comment: "加载中") : nil
    }

    // MARK: - Touch animations & haptics

    @objc private func didTouchDown() {
        // 轻触回弹:按下缩小,松开弹回
        UIView.animate(withDuration: 0.08) {
            self.transform = CGAffineTransform(scaleX: 0.96, y: 0.96)
        }
    }

    @objc private func cancelPress() {
        UIView.animate(withDuration: 0.2,
                       delay: 0,
                       usingSpringWithDamping: 0.8,
                       initialSpringVelocity: 2,
                       options: [.allowUserInteraction, .beginFromCurrentState],
                       animations: { self.transform = .identity },
                       completion: nil)
    }

    @objc private func didTap() {
        // 轻震
        let generator = UIImpactFeedbackGenerator(style: .light)
        generator.prepare()
        generator.impactOccurred()

        cancelPress()
        onTap?()
    }
}

3)控制器集成(表单校验、点击逻辑、埋点、布局)

final class LoginViewController: UIViewController {

    private let phoneField = UITextField()
    private let codeField = UITextField()
    private let loginButton = LoginButton()

    // 埋点
    private let analytics: AnalyticsLogging
    private let userID: String

    init(analytics: AnalyticsLogging, userID: String) {
        self.analytics = analytics
        self.userID = userID
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

    override func viewDidLoad() {
        super.viewDidLoad()

        // 配置文本框(略),添加 editingChanged 事件
        phoneField.keyboardType = .numberPad
        codeField.keyboardType = .numberPad
        phoneField.addTarget(self, action: #selector(textDidChange), for: .editingChanged)
        codeField.addTarget(self, action: #selector(textDidChange), for: .editingChanged)

        // 布局
        layoutUI()

        // 点击处理
        loginButton.onTap = { [weak self] in
            guard let self = self else { return }
            self.analytics.log(event: "auth_login_click", properties: ["user_id": self.userID])
            self.loginButton.startLoading()

            self.submitLogin { result in
                // 主线程更新 UI
                DispatchQueue.main.async {
                    switch result {
                    case .success:
                        self.analytics.log(event: "auth_login_success", properties: ["user_id": self.userID])
                        self.loginButton.stopLoading(restoreEnabledFromValidation: true)
                    case .failure:
                        self.analytics.log(event: "auth_login_failure", properties: ["user_id": self.userID])
                        self.loginButton.stopLoading(restoreEnabledFromValidation: true)
                    }
                    // 停止加载后,依据最新表单校验结果恢复启用态
                    self.updateLoginButtonEnabled()
                }
            }
        }

        // 初始校验
        updateLoginButtonEnabled()
    }

    @objc private func textDidChange() {
        updateLoginButtonEnabled()
    }

    private func updateLoginButtonEnabled() {
        let phone = phoneField.text ?? ""
        let code = codeField.text ?? ""
        let canSubmit = isValidPhone(phone) && isValidCode(code)
        loginButton.updateEnabledState(canSubmit: canSubmit)
    }

    // 根据业务规则实现(示例仅占位,替换为实际校验)
    private func isValidPhone(_ text: String) -> Bool {
        // TODO: 替换为真实的手机号校验规则
        return text.count >= 6
    }
    private func isValidCode(_ text: String) -> Bool {
        // TODO: 替换为真实的验证码校验规则
        return text.count >= 4
    }

    // 模拟登录请求(替换为真实网络调用)
    private func submitLogin(completion: @escaping (Result<Void, Error>) -> Void) {
        // 示例:1s 后回调成功/失败
        DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
            completion(.success(()))
        }
    }

    private func layoutUI() {
        view.addSubview(phoneField)
        view.addSubview(codeField)
        view.addSubview(loginButton)

        phoneField.translatesAutoresizingMaskIntoConstraints = false
        codeField.translatesAutoresizingMaskIntoConstraints = false
        loginButton.translatesAutoresizingMaskIntoConstraints = false

        let guide = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            // 仅示例:phoneField 与 codeField 的布局请按实际页面完成
            phoneField.topAnchor.constraint(equalTo: guide.topAnchor, constant: 40),
            phoneField.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 16),
            phoneField.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -16),

            codeField.topAnchor.constraint(equalTo: phoneField.bottomAnchor, constant: 12),
            codeField.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 16),
            codeField.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -16),

            // 登录按钮布局:高度48,左右安全边距16,与验证码输入框间距12
            loginButton.topAnchor.constraint(equalTo: codeField.bottomAnchor, constant: 12),
            loginButton.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 16),
            loginButton.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -16),
            loginButton.heightAnchor.constraint(equalToConstant: 48)
        ])
    }
}

4)无障碍与动态字体要点

  • 无障碍:
    • accessibilityLabel = "登录"
    • accessibilityTraits 包含 .button
    • 加载中:设置 accessibilityValue = "加载中",并通过 UIAccessibility.post(.announcement, argument:) 提示“正在登录”
  • 动态字体:
    • 使用 UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.systemFont(ofSize: 17))
    • titleLabel.adjustsFontForContentSizeCategory = true

5)埋点参考

  • 事件名:
    • 点击:auth_login_click
    • 成功:auth_login_success
    • 失败:auth_login_failure
  • 公共属性:
    • user_id: String(确保来源合法且已脱敏策略符合隐私要求)
  • 触发时机:
    • 点击:onTap 内第一时间上报
    • 成功/失败:网络回调后上报

交互特性

  • 启用/禁用:
    • isEnabled = 表单校验通过 && 非加载中
    • 禁用态背景使用 AppPrimaryDisabled,文字色保持可读(建议白或深浅对比充足)
  • 点击行为:
    • 轻触回弹动画:按下缩小(~0.96 倍),抬起弹回
    • 轻震:UIImpactFeedbackGenerator(style: .light).impactOccurred() 于触发点击时调用
    • 点击后:进入加载态,显示菊花,禁用交互,2s 超时自动恢复
  • 长按:无任何长按手势或菜单,不触发额外行为
  • VoiceOver:
    • 正常:读作“登录,按钮”
    • 加载中:额外播报“正在登录”,并在 accessibilityValue 中标记“加载中”
  • 指针/悬停(iPadOS/外接鼠标):可保持默认,或按需增加 pointer interaction(非必须)

使用场景

  • 短信验证码登录、手机号快捷登录等需要“表单校验—提交—等待结果”的入口按钮
  • 要求:
    • 后端接口返回时机不确定,需要按钮在网络回调前保持加载态
    • UI/UX 要求点击后有明显的反馈(动画与轻震)
    • 需要国际化与无障碍适配
  • 限制:
    • 规范高度固定为 48,如需完全遵循 Dynamic Type 放大,可改为“>=48”的最小高度策略

注意事项

  • 状态一致性
    • 加载态应与业务请求生命周期绑定;在请求成功/失败时优先结束加载,并取消已调度的超时任务
    • 停止加载后,按钮启用态应重新依据“手机号+验证码”校验结果刷新
  • 超时策略
    • 2s 为界面保护超时,非网络请求超时;不得与网络层超时逻辑混淆
    • 若请求先于 2s 返回,应取消超时任务,避免重复恢复状态
  • 线程与内存
    • 所有 UI 更新在主线程执行
    • 避免闭包对控制器/按钮的强引用循环([weak self])
  • 动态字体与可访问性
    • 字号缩放后需验证标题不截断;必要时允许按钮高度“>=48”
    • VoiceOver 开启时,避免频繁、冗余的无障碍播报
  • 主题与对比度
    • 深色模式下保证按钮背景与文字对比足够(建议对比度≥4.5:1)
    • 禁用态颜色应确保可见但不与启用态混淆
  • 触觉反馈
    • 仅在用户主动点击时触发;不要在程序化调用或重复点击场景下多次触发
  • 国际化
    • 所有可见文案由 "auth_login" 键驱动;不要在代码中硬编码“登录”
  • 埋点与隐私
    • user_id 仅用于必要的统计,遵守隐私策略,不记录敏感表单数据(如验证码)
    • 上报失败不影响按钮状态流转
  • 兼容性
    • iOS 15+ 优先使用 UIButton.Configuration 的 showsActivityIndicator
    • iOS 13–14 使用 UIActivityIndicatorView 子视图作为菊花占位,注意隐藏标题避免布局跳动
  • 可测试点
    • 表单边界值:空、最短、最长、非法字符
    • 快速多次点击:确保仅触发一次加载与一次请求
    • 深色模式、动态字体(含极大尺寸)、VoiceOver 开启场景
    • 网络快速成功/失败、超时先触发/后触发的竞态场景

Element Overview

A reusable UICollectionViewCell that displays a product card in a two-column grid. The cell contains:

  • Top: 16:9 cover image with a top-trailing promotion badge.
  • Middle: Title (up to 2 lines, truncated).
  • Middle row: Star rating with monthly sales.
  • Bottom row: Current price, optional strikethrough original price (remotely configurable to hide), and an “Add to Cart” button.
  • Async image loading with skeleton (shimmer) and progressive placeholder (skeleton → placeholder → final image fade-in).
  • Add-to-cart button triggers an animation and invokes a callback for inventory validation.
  • Supports iPhone SE to Pro Max in a two-column layout, Dark Mode, RTL, accessibility (reads title, price, rating), and localized currency.

Technical Implementation

import UIKit

// MARK: - View Models / Config

struct ProductCardViewModel: Hashable {
    let id: String
    let title: String
    let imageURL: URL?
    let rating: Double?        // e.g., 4.6
    let monthlySales: Int?     // e.g., 1200
    let currencyCode: String   // e.g., "USD"
    let locale: Locale         // e.g., Locale.current
    let currentPrice: Decimal
    let originalPrice: Decimal?
    let promoText: String?     // e.g., "SALE", nil to hide
}

struct ProductCardUIFlags {
    let hideOriginalPrice: Bool
}

final class ImageLoader {
    static let shared = ImageLoader()
    private let cache = NSCache<NSURL, UIImage>()
    private var tasks = [UIImageView: URLSessionDataTask]()

    func load(_ url: URL?, into imageView: UIImageView, placeholder: UIImage? = nil, crossfade: Bool = true) {
        // Cancel in-flight
        tasks[imageView]?.cancel()
        imageView.image = placeholder

        guard let url else { return }
        if let cached = cache.object(forKey: url as NSURL) {
            imageView.image = cached
            return
        }

        let task = URLSession.shared.dataTask(with: url) { [weak self, weak imageView] data, _, _ in
            guard let self, let imageView, let data, let image = UIImage(data: data) else { return }
            self.cache.setObject(image, forKey: url as NSURL)
            DispatchQueue.main.async {
                if crossfade {
                    UIView.transition(with: imageView, duration: 0.25, options: .transitionCrossDissolve) {
                        imageView.image = image
                    }
                } else {
                    imageView.image = image
                }
            }
        }
        tasks[imageView] = task
        task.resume()
    }

    func cancel(for imageView: UIImageView) {
        tasks[imageView]?.cancel()
        tasks[imageView] = nil
    }
}

// Optional: if you need true progressive decoding, consider a proven library (e.g., SDWebImage) with progressive loading.

// MARK: - Skeleton Shimmer

final class SkeletonView: UIView {
    private let gradient = CAGradientLayer()
    private var animating = false

    override class var layerClass: AnyClass { CAGradientLayer.self }

    override init(frame: CGRect) {
        super.init(frame: frame)
        isUserInteractionEnabled = false
        setup()
    }
    required init?(coder: NSCoder) { fatalError() }

    private func setup() {
        let gradient = self.layer as! CAGradientLayer
        gradient.startPoint = CGPoint(x: 0, y: 0.5)
        gradient.endPoint = CGPoint(x: 1, y: 0.5)
        gradient.colors = [
            UIColor.systemFill.withAlphaComponent(0.6).cgColor,
            UIColor.systemFill.withAlphaComponent(0.3).cgColor,
            UIColor.systemFill.withAlphaComponent(0.6).cgColor
        ]
        gradient.locations = [0, 0.5, 1]
        layer.cornerRadius = 8
        clipsToBounds = true
        isHidden = true
    }

    func start() {
        guard !animating else { return }
        isHidden = false
        animating = true

        let animation = CABasicAnimation(keyPath: "locations")
        animation.fromValue = [ -1, -0.5, 0 ]
        animation.toValue = [ 1, 1.5, 2 ]
        animation.duration = 1.2
        animation.repeatCount = .infinity
        (layer as! CAGradientLayer).add(animation, forKey: "skeleton")
    }

    func stop() {
        animating = false
        (layer as! CAGradientLayer).removeAnimation(forKey: "skeleton")
        isHidden = true
    }
}

// MARK: - ProductCardCell

protocol ProductCardCellDelegate: AnyObject {
    func productCardCellDidTapAddToCart(_ cell: ProductCardCell, productID: String)
}

final class ProductCardCell: UICollectionViewCell {

    // Public
    weak var delegate: ProductCardCellDelegate?
    var onAddToCart: ((String) -> Void)? // alternative callback
    private(set) var productID: String?

    // UI
    private let container = UIView()
    private let imageView = UIImageView()
    private let promoBadge = UILabel()
    private let titleLabel = UILabel()
    private let ratingStack = UIStackView()
    private let starImageView = UIImageView()
    private let ratingLabel = UILabel()
    private let priceStack = UIStackView()
    private let currentPriceLabel = UILabel()
    private let originalPriceLabel = UILabel()
    private let addButton = UIButton(type: .system)

    private let imageSkeleton = SkeletonView()
    private let textSkeleton1 = SkeletonView()
    private let textSkeleton2 = SkeletonView()

    // Constraints helpers
    private var imageAspect: NSLayoutConstraint!

    // Formatting
    private lazy var priceFormatter: NumberFormatter = {
        let nf = NumberFormatter()
        nf.numberStyle = .currency
        return nf
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
        setupLayout()
        setupAccessibility()
        contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)
    }

    required init?(coder: NSCoder) { fatalError() }

    override func prepareForReuse() {
        super.prepareForReuse()
        ImageLoader.shared.cancel(for: imageView)
        imageView.image = nil
        promoBadge.isHidden = true
        originalPriceLabel.isHidden = true
        titleLabel.text = nil
        ratingLabel.text = nil
        currentPriceLabel.text = nil

        stopSkeleton()
        startSkeleton()
    }

    // MARK: Setup

    private func setupUI() {
        contentView.backgroundColor = .secondarySystemBackground
        contentView.layer.cornerRadius = 12
        contentView.layer.masksToBounds = true

        container.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(container)

        // Image
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        imageView.translatesAutoresizingMaskIntoConstraints = false

        // Promo badge
        promoBadge.font = .preferredFont(forTextStyle: .caption2)
        promoBadge.adjustsFontForContentSizeCategory = true
        promoBadge.textColor = .white
        promoBadge.backgroundColor = .systemRed
        promoBadge.layer.cornerRadius = 4
        promoBadge.clipsToBounds = true
        promoBadge.textAlignment = .center
        promoBadge.setContentHuggingPriority(.required, for: .horizontal)
        promoBadge.setContentCompressionResistancePriority(.required, for: .horizontal)
        promoBadge.translatesAutoresizingMaskIntoConstraints = false

        // Title
        titleLabel.font = .preferredFont(forTextStyle: .body)
        titleLabel.adjustsFontForContentSizeCategory = true
        titleLabel.numberOfLines = 2
        titleLabel.lineBreakMode = .byTruncatingTail
        titleLabel.translatesAutoresizingMaskIntoConstraints = false

        // Rating
        ratingStack.axis = .horizontal
        ratingStack.spacing = 4
        ratingStack.alignment = .center
        ratingStack.translatesAutoresizingMaskIntoConstraints = false
        ratingStack.isLayoutMarginsRelativeArrangement = false

        starImageView.image = UIImage(systemName: "star.fill")
        starImageView.tintColor = .systemYellow
        starImageView.setContentHuggingPriority(.required, for: .horizontal)
        starImageView.translatesAutoresizingMaskIntoConstraints = false
        starImageView.widthAnchor.constraint(equalToConstant: 14).isActive = true
        starImageView.heightAnchor.constraint(equalToConstant: 14).isActive = true

        ratingLabel.font = .preferredFont(forTextStyle: .footnote)
        ratingLabel.adjustsFontForContentSizeCategory = true
        ratingLabel.textColor = .secondaryLabel
        ratingLabel.translatesAutoresizingMaskIntoConstraints = false

        ratingStack.addArrangedSubview(starImageView)
        ratingStack.addArrangedSubview(ratingLabel)

        // Price row
        priceStack.axis = .horizontal
        priceStack.alignment = .firstBaseline
        priceStack.spacing = 6
        priceStack.translatesAutoresizingMaskIntoConstraints = false

        currentPriceLabel.font = .preferredFont(forTextStyle: .headline)
        currentPriceLabel.adjustsFontForContentSizeCategory = true
        currentPriceLabel.textColor = .label

        originalPriceLabel.font = .preferredFont(forTextStyle: .subheadline)
        originalPriceLabel.adjustsFontForContentSizeCategory = true
        originalPriceLabel.textColor = .secondaryLabel

        priceStack.addArrangedSubview(currentPriceLabel)
        priceStack.addArrangedSubview(originalPriceLabel)

        // Add button
        var config = UIButton.Configuration.filled()
        config.title = NSLocalizedString("Add to Cart", comment: "Add to Cart button")
        config.image = UIImage(systemName: "cart.badge.plus")
        config.imagePlacement = .leading
        config.imagePadding = 6
        config.cornerStyle = .medium
        addButton.configuration = config
        addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
        addButton.translatesAutoresizingMaskIntoConstraints = false

        // Skeletons
        [imageSkeleton, textSkeleton1, textSkeleton2].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            $0.isHidden = true
        }

        // Assemble
        container.addSubview(imageView)
        container.addSubview(promoBadge)
        container.addSubview(titleLabel)
        container.addSubview(ratingStack)
        container.addSubview(priceStack)
        container.addSubview(addButton)

        container.addSubview(imageSkeleton)
        container.addSubview(textSkeleton1)
        container.addSubview(textSkeleton2)

        // RTL considerations
        ratingStack.semanticContentAttribute = .forceLeftToRight // stars should not reverse
    }

    private func setupLayout() {
        NSLayoutConstraint.activate([
            container.topAnchor.constraint(equalTo: contentView.topAnchor),
            container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),

            imageView.topAnchor.constraint(equalTo: container.topAnchor),
            imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
            imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
        ])

        imageAspect = imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 9.0/16.0)
        imageAspect.isActive = true

        // Promo badge top-trailing over image
        NSLayoutConstraint.activate([
            promoBadge.topAnchor.constraint(equalTo: imageView.topAnchor, constant: 8),
            promoBadge.trailingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: -8)
        ])

        // Below image
        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 8),
            titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8),
            titleLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8),

            ratingStack.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6),
            ratingStack.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
            ratingStack.trailingAnchor.constraint(lessThanOrEqualTo: titleLabel.trailingAnchor),

            priceStack.topAnchor.constraint(equalTo: ratingStack.bottomAnchor, constant: 8),
            priceStack.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
            priceStack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8),

            addButton.centerYAnchor.constraint(equalTo: priceStack.firstBaselineAnchor),
            addButton.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
            addButton.leadingAnchor.constraint(greaterThanOrEqualTo: priceStack.trailingAnchor, constant: 8)
        ])

        // Skeleton placements
        NSLayoutConstraint.activate([
            imageSkeleton.topAnchor.constraint(equalTo: imageView.topAnchor),
            imageSkeleton.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
            imageSkeleton.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
            imageSkeleton.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),

            textSkeleton1.topAnchor.constraint(equalTo: titleLabel.topAnchor),
            textSkeleton1.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
            textSkeleton1.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
            textSkeleton1.heightAnchor.constraint(equalToConstant: 14),

            textSkeleton2.topAnchor.constraint(equalTo: textSkeleton1.bottomAnchor, constant: 6),
            textSkeleton2.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
            textSkeleton2.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: -40),
            textSkeleton2.heightAnchor.constraint(equalToConstant: 14),
        ])
    }

    // MARK: Configure

    func configure(with model: ProductCardViewModel, uiFlags: ProductCardUIFlags) {
        self.productID = model.id

        // Image loading with skeleton
        startSkeleton()
        ImageLoader.shared.load(model.imageURL, into: imageView, placeholder: nil, crossfade: true)
        // Stop skeleton when image settles; a simple approach is to stop in layoutSubviews after image is set.
        // Here we stop after a short async tick to allow crossfade.
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
            guard let self else { return }
            if self.imageView.image != nil { self.stopSkeleton() }
        }

        // Promo
        if let promo = model.promoText, !promo.isEmpty {
            promoBadge.isHidden = false
            promoBadge.text = " \(promo) "
        } else {
            promoBadge.isHidden = true
        }

        // Title
        titleLabel.text = model.title

        // Rating + monthly sales
        if let rating = model.rating, let sales = model.monthlySales {
            ratingLabel.text = String(format: "%.1f · %@/mo", rating, Self.formatSales(sales, locale: model.locale))
            ratingStack.isHidden = false
        } else {
            ratingLabel.text = nil
            ratingStack.isHidden = true
        }

        // Prices
        priceFormatter.locale = model.locale
        priceFormatter.currencyCode = model.currencyCode

        currentPriceLabel.text = priceFormatter.string(from: model.currentPrice as NSDecimalNumber)

        if let original = model.originalPrice,
           !uiFlags.hideOriginalPrice,
           original > model.currentPrice,
           let originalStr = priceFormatter.string(from: original as NSDecimalNumber) {
            let attrs: [NSAttributedString.Key: Any] = [
                .strikethroughStyle: NSUnderlineStyle.single.rawValue,
                .foregroundColor: UIColor.secondaryLabel
            ]
            originalPriceLabel.attributedText = NSAttributedString(string: originalStr, attributes: attrs)
            originalPriceLabel.isHidden = false
        } else {
            originalPriceLabel.isHidden = true
        }

        updateAccessibility(model: model, hasOriginalPrice: !originalPriceLabel.isHidden)
    }

    private static func formatSales(_ sales: Int, locale: Locale) -> String {
        // Simple compact formatting (e.g., 1.2K)
        let fmt = NumberFormatter()
        fmt.locale = locale
        fmt.numberStyle = .decimal
        if sales >= 1000 {
            let k = Double(sales) / 1000.0
            return String(format: locale.identifier.hasPrefix("zh") ? "%.1fK" : "%.1fK", k)
        }
        return fmt.string(from: sales as NSNumber) ?? "\(sales)"
    }

    // MARK: Actions

    @objc private func addTapped() {
        // Haptic
        let generator = UIImpactFeedbackGenerator(style: .light)
        generator.impactOccurred()

        // Button micro-interaction
        UIView.animate(withDuration: 0.08, animations: {
            self.addButton.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
        }, completion: { _ in
            UIView.animate(withDuration: 0.2,
                           delay: 0,
                           usingSpringWithDamping: 0.6,
                           initialSpringVelocity: 0.8,
                           options: .allowUserInteraction,
                           animations: {
                self.addButton.transform = .identity
            }, completion: nil)
        })

        guard let id = productID else { return }
        // Delegate/callback for inventory validation in VC
        delegate?.productCardCellDidTapAddToCart(self, productID: id)
        onAddToCart?(id)
    }

    // Called by VC after inventory check result (success path)
    func playAddToCartSuccessAnimation(to cartTarget: CGPoint? = nil, in container: UIView? = nil) {
        // Optional "fly-to-cart" animation
        guard let snapshot = imageView.snapshotView(afterScreenUpdates: false),
              let root = container ?? window else { return }

        let startFrame = imageView.convert(imageView.bounds, to: root)
        snapshot.frame = startFrame
        snapshot.layer.cornerRadius = imageView.layer.cornerRadius
        snapshot.layer.masksToBounds = true
        root.addSubview(snapshot)

        let endPoint = cartTarget ?? CGPoint(x: startFrame.maxX, y: startFrame.minY - 40)

        UIView.animate(withDuration: 0.7, delay: 0, options: .curveEaseIn) {
            snapshot.frame = CGRect(origin: endPoint, size: CGSize(width: 10, height: 10))
            snapshot.alpha = 0.2
        } completion: { _ in
            snapshot.removeFromSuperview()
        }
    }

    // MARK: Skeleton helpers

    private func startSkeleton() {
        [imageSkeleton, textSkeleton1, textSkeleton2].forEach { $0.start() }
    }

    private func stopSkeleton() {
        [imageSkeleton, textSkeleton1, textSkeleton2].forEach { $0.stop() }
    }

    // MARK: Accessibility

    private func setupAccessibility() {
        isAccessibilityElement = true
        accessibilityTraits.insert(.button) // acts like a composite with add action
        accessibilityCustomActions = [
            UIAccessibilityCustomAction(name: NSLocalizedString("Add to Cart", comment: "VO action"), target: self, selector: #selector(addTapped))
        ]
    }

    private func updateAccessibility(model: ProductCardViewModel, hasOriginalPrice: Bool) {
        let priceText = currentPriceLabel.text ?? ""
        var parts: [String] = [model.title, priceText]

        if hasOriginalPrice, let original = originalPriceLabel.attributedText?.string {
            parts.append(NSLocalizedString("Original", comment: "Original price prefix") + " " + original)
        }
        if let rating = model.rating {
            let ratingStr = String(format: NSLocalizedString("Rating %.1f", comment: "Rating value"), rating)
            parts.append(ratingStr)
        }
        accessibilityLabel = parts.joined(separator: ", ")
    }
}

// MARK: - Two-column Compositional Layout (iOS 13+)

enum ProductGridLayoutBuilder {
    static func twoColumnLayout(interItemSpacing: CGFloat = 8,
                                contentInsets: NSDirectionalEdgeInsets = .init(top: 8, leading: 8, bottom: 8, trailing: 8)
    ) -> UICollectionViewCompositionalLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5),
                                              heightDimension: .estimated(320))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = .init(top: interItemSpacing/2, leading: interItemSpacing/2, bottom: interItemSpacing/2, trailing: interItemSpacing/2)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .estimated(320))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, item])

        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = contentInsets
        return UICollectionViewCompositionalLayout(section: section)
    }
}

Key configuration parameters:

  • Image aspect ratio: 16:9 via heightAnchor = widthAnchor × 9/16.
  • Two-column grid: NSCollectionLayoutItem widthDimension = .fractionalWidth(0.5); estimated height; inter-item spacing 8 pt; section insets 8 pt.
  • Font styles: title = .body, rating = .footnote, current price = .headline, original price = .subheadline.
  • Remote flag: ProductCardUIFlags.hideOriginalPrice to control visibility of original price.
  • Accessibility: isAccessibilityElement = true; custom action “Add to Cart”; composed accessibilityLabel includes title, price, rating.
  • RTL: use leading/trailing constraints; rating star stack forced left-to-right; other stacks inherit system semantics.
  • Dark Mode: system colors (label, secondaryLabel, secondarySystemBackground, systemRed, systemYellow).

Interaction Features

  • Image loading:
    • On configure, skeleton shimmer appears over image and title region.
    • Image loads asynchronously. On completion, a crossfade updates the image and skeleton stops.
  • Promo badge:
    • Visible only when promoText exists; anchored top-trailing inside the image.
  • Title:
    • Up to 2 lines, truncates tail; responds to Dynamic Type.
  • Rating and monthly sales:
    • Displays “4.6 · 1.2K/mo”; hidden if data is unavailable.
  • Price row:
    • Current price formatted by NumberFormatter with provided locale and currency code.
    • Original price shows with strikethrough when present, higher than current price, and remote flag allows it.
  • Add to Cart:
    • Tapping triggers a spring scale animation and invokes delegate.onAddToCart for inventory check.
    • After validation success, call cell.playAddToCartSuccessAnimation(from VC) to animate a “fly-to-cart.”
  • Accessibility:
    • VoiceOver reads title, price, and rating.
    • Custom action “Add to Cart” available to VoiceOver users.

Usage Scenarios

  • Product listing grids where two-column layout is required across iPhone SE to Pro Max.
  • Cards with optional promotions, ratings, and sales performance.
  • Price presentation with localized currency and operator-driven configuration for original price visibility.
  • Supports markets requiring RTL layout and localized number formats.

Limitations:

  • The included ImageLoader performs basic async loading and caching but does not implement true progressive decoding; use a specialized library if necessary.
  • Estimated item height is used; final heights may vary by content within the 2-line cap for titles.

Notes and Best Practices

  • Reuse and cancellation:
    • Always cancel image tasks and reset UI state in prepareForReuse.
    • Restart skeleton on reuse; stop it after image appears.
  • Layout:
    • Maintain 16:9 image aspect with an explicit constraint.
    • Use leading/trailing constraints for RTL compatibility.
  • Dynamic Type:
    • Use preferredFont and adjustsFontForContentSizeCategory to respect system text size settings.
  • Accessibility:
    • Compose a concise accessibilityLabel with title, current price, optional original price, and rating.
    • Provide a UIAccessibilityCustomAction for “Add to Cart.”
  • Currency and locale:
    • Inject locale and currencyCode via view model; do not hardcode.
    • Validate originalPrice > currentPrice before showing strikethrough.
  • Dark Mode:
    • Use system colors; avoid hardcoded colors for text and backgrounds.
  • Performance:
    • Use NSCache for images; avoid unnecessary layer rasterization.
    • Keep rounded corners on contentView; imageView clipsToBounds is required for aspect fill.
  • Remote configuration:
    • Evaluate ProductCardUIFlags.hideOriginalPrice at configure time; keep UI logic deterministic.
  • Inventory callback:
    • Let the view controller handle stock validation; upon success, call playAddToCartSuccessAnimation providing the cart icon’s point in window coordinates to create a coherent animation.

Integration sketch (in a view controller):

// 1) Layout
collectionView.setCollectionViewLayout(ProductGridLayoutBuilder.twoColumnLayout(), animated: false)
collectionView.register(ProductCardCell.self, forCellWithReuseIdentifier: "ProductCell")

// 2) Cell configuration (diffable data source example omitted)
func configure(cell: ProductCardCell, with model: ProductCardViewModel, flags: ProductCardUIFlags) {
    cell.configure(with: model, uiFlags: flags)
    cell.delegate = self
    cell.onAddToCart = { [weak self] productID in
        self?.checkInventoryAndAdd(productID: productID) { success, cartPoint in
            if success {
                cell.playAddToCartSuccessAnimation(to: cartPoint, in: self?.view)
            } else {
                // show out-of-stock feedback
            }
        }
    }
}

This implementation adheres to UIKit best practices, supports accessibility, RTL, Dark Mode, localized currency formatting, and provides the required interactions and layout behavior for a production-grade product card cell.

元素概述

聊天输入栏作为 inputAccessoryView,随键盘出现/隐藏自动贴合底部安全区。核心能力包含:

  • 可自增长文本域:最小 1 行、最大 6 行,带占位符“发送消息……”
  • 基础控件:发送按钮、附件按钮、语音切换
  • 文本规则:发送前去除首尾空格,空内容禁发;换行发送可由设置开关控制
  • 增强输入:支持 @提及 与 / 斜杠命令建议弹层
  • 粘贴图片:检测到图片时唤起预览模块
  • 无障碍:VoiceOver 为控件配置合适的描述和顺序
  • 埋点:输入时长、发送、撤回
  • 其他:国际化、本地化与深色模式适配;错误提示用轻提示

技术实现

下面给出基于 UIKit + Swift 的参考实现,便于培训与快速落地。最低系统建议 iOS 13+。

1)输入栏视图 ChatInputBar(inputAccessoryView)

import UIKit

protocol ChatInputBarDelegate: AnyObject {
    func inputBarDidTapSend(_ bar: ChatInputBar, text: String)
    func inputBarDidTapAttach(_ bar: ChatInputBar)
    func inputBarDidToggleVoice(_ bar: ChatInputBar, isVoiceMode: Bool)
    func inputBar(_ bar: ChatInputBar, didPasteImages images: [UIImage])
    func inputBar(_ bar: ChatInputBar, didChangeText text: String)
    func inputBar(_ bar: ChatInputBar, didTriggerSuggestion trigger: ChatInputBar.SuggestionTrigger?)
}

final class PasteAwareTextView: UITextView {
    weak var pasteImageHandler: (([UIImage]) -> Void)?

    override func paste(_ sender: Any?) {
        let pb = UIPasteboard.general
        if let images = pb.images, !images.isEmpty {
            pasteImageHandler?(images)
        } else {
            super.paste(sender)
        }
    }
}

final class ChatInputBar: UIView, UITextViewDelegate {

    // 触发器类型:@ 或 /
    enum SuggestionTrigger {
        case mention(query: String)   // @xxx
        case command(query: String)   // /xxx
    }

    weak var delegate: ChatInputBarDelegate?

    // 可设置:是否回车即发送
    var sendOnReturn: Bool = UserDefaults.standard.bool(forKey: "SendOnReturn") {
        didSet { textView.returnKeyType = sendOnReturn ? .send : .default }
    }

    // UI 元素
    private let attachButton = UIButton(type: .system)
    private let voiceButton = UIButton(type: .system)
    private let sendButton = UIButton(type: .system)
    private let textView = PasteAwareTextView()

    // 占位符
    private let placeholderLabel = UILabel()

    // 样式与布局
    private let contentStack = UIStackView()
    private var textViewHeightConstraint: NSLayoutConstraint!

    // 行高控制
    private var minTextHeight: CGFloat { ceil(textLineHeight + textInsets) }
    private var maxTextHeight: CGFloat { ceil(textLineHeight * 6 + textInsets) }
    private var textLineHeight: CGFloat { textView.font?.lineHeight ?? 17 }
    private var textInsets: CGFloat {
        textView.textContainerInset.top + textView.textContainerInset.bottom
    }

    // 自适应高度
    override var intrinsicContentSize: CGSize {
        // 让系统使用自动布局结果 + 底部安全区
        let fitting = contentStack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
        return CGSize(width: UIView.noIntrinsicMetric, height: fitting.height + safeAreaInsets.bottom)
    }

    override func safeAreaInsetsDidChange() {
        super.safeAreaInsetsDidChange()
        invalidateIntrinsicContentSize()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
        setupAccessibility()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupUI()
        setupAccessibility()
    }

    private func setupUI() {
        backgroundColor = .secondarySystemBackground
        preservesSuperviewLayoutMargins = true
        layoutMargins = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12)

        // 按钮基础配置(SF Symbols,自动深色适配)
        attachButton.setImage(UIImage(systemName: "paperclip"), for: .normal)
        voiceButton.setImage(UIImage(systemName: "mic"), for: .normal)
        sendButton.setImage(UIImage(systemName: "paperplane.fill"), for: .normal)
        sendButton.tintColor = .systemBlue

        // 可点击区域建议 >= 44pt
        [attachButton, voiceButton, sendButton].forEach {
            $0.contentEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
            $0.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
            $0.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
        }

        // 文字视图
        textView.backgroundColor = .systemBackground
        textView.layer.cornerRadius = 8
        textView.textContainer.lineFragmentPadding = 8
        textView.textContainerInset = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 6)
        textView.isScrollEnabled = false
        textView.delegate = self
        textView.font = .preferredFont(forTextStyle: .body)
        textView.adjustsFontForContentSizeCategory = true
        textView.returnKeyType = sendOnReturn ? .send : .default
        textView.pasteImageHandler = { [weak self] images in
            guard let self = self else { return }
            self.delegate?.inputBar(self, didPasteImages: images)
        }

        // 占位符
        placeholderLabel.text = NSLocalizedString("send_message_placeholder", comment: "发送消息……")
        placeholderLabel.textColor = .secondaryLabel
        placeholderLabel.font = textView.font

        textView.addSubview(placeholderLabel)
        placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor,
                                                      constant: textView.textContainerInset.left + textView.textContainer.lineFragmentPadding),
            placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor,
                                                  constant: textView.textContainerInset.top),
            placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: textView.trailingAnchor)
        ])

        // 组合布局
        contentStack.axis = .horizontal
        contentStack.alignment = .center
        contentStack.spacing = 8
        addSubview(contentStack)
        contentStack.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            contentStack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
            contentStack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
            contentStack.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
            contentStack.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor)
        ])

        // 左侧:附件、语音;中间:文本;右侧:发送
        contentStack.addArrangedSubview(attachButton)
        contentStack.addArrangedSubview(voiceButton)
        contentStack.addArrangedSubview(textView)
        contentStack.addArrangedSubview(sendButton)

        // 文本高度约束(随内容变化)
        textViewHeightConstraint = textView.heightAnchor.constraint(equalToConstant: minTextHeight)
        textViewHeightConstraint.isActive = true

        // 事件
        attachButton.addTarget(self, action: #selector(onAttach), for: .touchUpInside)
        voiceButton.addTarget(self, action: #selector(onVoice), for: .touchUpInside)
        sendButton.addTarget(self, action: #selector(onSend), for: .touchUpInside)

        updateUIState()
    }

    private func setupAccessibility() {
        // 无障碍描述与顺序(从左到右)
        attachButton.accessibilityLabel = NSLocalizedString("access_attach", comment: "添加附件")
        attachButton.accessibilityTraits = .button

        voiceButton.accessibilityLabel = NSLocalizedString("access_voice_toggle", comment: "语音输入")
        voiceButton.accessibilityHint = NSLocalizedString("access_voice_toggle_hint", comment: "切换语音输入模式")
        voiceButton.accessibilityTraits = .button

        textView.accessibilityLabel = NSLocalizedString("access_message_input", comment: "消息输入框")
        textView.accessibilityTraits = .allowsDirectInteraction

        sendButton.accessibilityLabel = NSLocalizedString("access_send", comment: "发送")
        sendButton.accessibilityTraits = .button

        accessibilityElements = [attachButton, voiceButton, textView, sendButton]
    }

    // MARK: - Actions

    @objc private func onAttach() {
        delegate?.inputBarDidTapAttach(self)
    }

    @objc private func onVoice() {
        // 简化为“切换语音模式”的开关逻辑(UI 切换由上层控制)
        let isVoice = voiceButton.isSelected.toggle()
        voiceButton.tintColor = isVoice ? .systemGreen : .label
        delegate?.inputBarDidToggleVoice(self, isVoiceMode: isVoice)
    }

    @objc private func onSend() {
        let trimmed = textView.text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        delegate?.inputBarDidTapSend(self, text: trimmed)
    }

    // MARK: - UITextViewDelegate

    func textViewDidChange(_ textView: UITextView) {
        placeholderLabel.isHidden = !textView.text.isEmpty
        adjustTextViewHeight()
        updateUIState()
        delegate?.inputBar(self, didChangeText: textView.text)
        updateSuggestionTrigger()
    }

    func textView(_ textView: UITextView,
                  shouldChangeTextIn range: NSRange,
                  replacementText text: String) -> Bool {
        if sendOnReturn && text == "\n" {
            onSend()
            return false
        }
        return true
    }

    // MARK: - Helpers

    private func adjustTextViewHeight() {
        // 计算拟合高度
        let target = CGSize(width: textView.bounds.width, height: CGFloat.greatestFiniteMagnitude)
        let fitting = textView.sizeThatFits(target).height
        let clamped = min(max(fitting, minTextHeight), maxTextHeight)

        textView.isScrollEnabled = fitting > maxTextHeight
        if abs(textViewHeightConstraint.constant - clamped) > 0.5 {
            textViewHeightConstraint.constant = clamped
            invalidateIntrinsicContentSize()
            // 通知系统更新 inputAccessoryView 高度
            superview?.setNeedsLayout()
            superview?.layoutIfNeeded()
        }
    }

    private func updateUIState() {
        let trimmed = textView.text.trimmingCharacters(in: .whitespacesAndNewlines)
        sendButton.isEnabled = !trimmed.isEmpty
        sendButton.alpha = sendButton.isEnabled ? 1.0 : 0.5
    }

    private func updateSuggestionTrigger() {
        let fullText = textView.text as NSString
        let cursor = textView.selectedRange.location
        guard cursor <= fullText.length else {
            delegate?.inputBar(self, didTriggerSuggestion: nil)
            return
        }
        // 提取光标前的 token
        let prefix = fullText.substring(to: cursor)
        if let token = prefix.split(whereSeparator: { $0 == " " || $0 == "\n" }).last {
            if token.first == "@" {
                let q = String(token.dropFirst())
                delegate?.inputBar(self, didTriggerSuggestion: .mention(query: q))
                return
            } else if token.first == "/" {
                let q = String(token.dropFirst())
                delegate?.inputBar(self, didTriggerSuggestion: .command(query: q))
                return
            }
        }
        delegate?.inputBar(self, didTriggerSuggestion: nil)
    }

    // 对外方法
    func insertSuggestionText(_ text: String, replacing trigger: SuggestionTrigger) {
        guard let range = rangeForTrigger(trigger) else { return }
        let ns = textView.text as NSString
        let newText = ns.replacingCharacters(in: range, with: text + " ")
        textView.text = newText
        textViewDidChange(textView)
    }

    private func rangeForTrigger(_ trigger: SuggestionTrigger) -> NSRange? {
        let ns = textView.text as NSString
        let cursor = textView.selectedRange.location
        let prefix = ns.substring(to: cursor)
        guard let tokenRange = prefix.range(of: #"([@/][^\s\n]*)$"#, options: .regularExpression) else { return nil }
        let lower = prefix.distance(from: prefix.startIndex, to: tokenRange.lowerBound)
        let upper = prefix.distance(from: prefix.startIndex, to: tokenRange.upperBound)
        return NSRange(location: lower, length: upper - lower)
    }

    func clearText() {
        textView.text = ""
        textViewDidChange(textView)
    }
}

要点:

  • inputAccessoryView 自适应安全区:通过 intrinsicContentSize 将 safeAreaInsets.bottom 加入计算,系统在键盘弹出时会自动贴齐。
  • 文本框自增长:使用 sizeThatFits + 高度约束,并在超过 6 行时开启内部滚动。
  • 占位符:UILabel 叠加在 UITextView 内部,随文本显隐。
  • 回车发送:通过 shouldChangeTextIn 拦截换行;sendOnReturn 控制 returnKeyType 和行为。
  • 粘贴图片:重写 paste(_:) 检测 UIPasteboard.general.images,交由上层弹出预览。

2)建议弹层 SuggestionView(@ 与 /)

示例用 UITableView 作为建议面板,锚定在输入栏上方。

final class SuggestionView: UIView, UITableViewDataSource, UITableViewDelegate {
    private let tableView = UITableView(frame: .zero, style: .plain)
    var onSelect: ((String) -> Void)?
    var items: [String] = [] {
        didSet { tableView.reloadData() }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = .systemBackground
        layer.cornerRadius = 8
        layer.borderColor = UIColor.separator.cgColor
        layer.borderWidth = 1 / UIScreen.main.scale

        addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            tableView.leadingAnchor.constraint(equalTo: leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: trailingAnchor),
            tableView.topAnchor.constraint(equalTo: topAnchor),
            tableView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
        tableView.dataSource = self
        tableView.delegate = self
        tableView.rowHeight = 44
    }

    required init?(coder: NSCoder) { fatalError() }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell") ?? UITableViewCell(style: .default, reuseIdentifier: "cell")
        cell.textLabel?.text = items[indexPath.row]
        return cell
    }
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        onSelect?(items[indexPath.row])
    }
}

3)控制器集成(inputAccessoryView、建议弹层、发送逻辑、埋点)

final class ChatViewController: UIViewController, ChatInputBarDelegate {

    private lazy var inputBar = ChatInputBar()
    private let suggestionView = SuggestionView()

    // 埋点数据
    private var inputStartTime: Date?
    private let analytics = Analytics()

    // 假数据
    private let mentionCandidates = ["@Alice", "@Bob", "@Carol", "@David"]
    private let commandCandidates = ["/giphy", "/shrug", "/me", "/topic"]

    override var canBecomeFirstResponder: Bool { true }
    override var inputAccessoryView: UIView? { inputBar }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground

        // 输入栏委托
        inputBar.delegate = self

        // 建议视图
        suggestionView.isHidden = true
        view.addSubview(suggestionView)
        suggestionView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            suggestionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 12),
            suggestionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12),
            suggestionView.bottomAnchor.constraint(equalTo: inputBar.topAnchor, constant: -8),
            suggestionView.heightAnchor.constraint(lessThanOrEqualToConstant: 240)
        ])
        suggestionView.onSelect = { [weak self] text in
            guard let self = self else { return }
            if let current = self.currentTrigger {
                self.inputBar.insertSuggestionText(text, replacing: current)
                self.hideSuggestions()
            }
        }
    }

    // MARK: - ChatInputBarDelegate

    func inputBarDidTapSend(_ bar: ChatInputBar, text: String) {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else {
            showToast(NSLocalizedString("error_empty_message", comment: "内容不能为空"))
            return
        }
        analytics.logSend(messageLength: trimmed.count, inputDuration: inputDuration())
        // TODO: 发送消息到服务端
        bar.clearText()
        hideSuggestions()
    }

    func inputBarDidTapAttach(_ bar: ChatInputBar) {
        // TODO: 打开系统文件/相册选择器
    }

    func inputBarDidToggleVoice(_ bar: ChatInputBar, isVoiceMode: Bool) {
        // TODO: 切换到语音 UI(开始/结束录音等)
    }

    func inputBar(_ bar: ChatInputBar, didPasteImages images: [UIImage]) {
        // TODO: 弹出预览模块(多选/单张确认)
        // 示例:present 预览控制器
    }

    func inputBar(_ bar: ChatInputBar, didChangeText text: String) {
        if inputStartTime == nil && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            inputStartTime = Date()
        }
    }

    private var currentTrigger: ChatInputBar.SuggestionTrigger?
    func inputBar(_ bar: ChatInputBar, didTriggerSuggestion trigger: ChatInputBar.SuggestionTrigger?) {
        currentTrigger = trigger
        guard let trigger = trigger else {
            hideSuggestions(); return
        }

        switch trigger {
        case .mention(let q):
            let data = q.isEmpty ? mentionCandidates : mentionCandidates.filter { $0.lowercased().contains(q.lowercased()) }
            showSuggestions(data)
        case .command(let q):
            let data = q.isEmpty ? commandCandidates : commandCandidates.filter { $0.lowercased().contains(q.lowercased()) }
            showSuggestions(data)
        }
    }

    // MARK: - 建议面板显隐

    private func showSuggestions(_ items: [String]) {
        guard !items.isEmpty else { hideSuggestions(); return }
        suggestionView.items = items
        suggestionView.isHidden = false
    }

    private func hideSuggestions() {
        suggestionView.isHidden = true
        suggestionView.items = []
    }

    // MARK: - 轻提示

    private func showToast(_ text: String) {
        let label = PaddingLabel()
        label.text = text
        label.textColor = .white
        label.backgroundColor = UIColor.black.withAlphaComponent(0.8)
        label.layer.cornerRadius = 8
        label.layer.masksToBounds = true
        label.numberOfLines = 0
        label.textAlignment = .center

        view.addSubview(label)
        label.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.bottomAnchor.constraint(equalTo: inputBar.topAnchor, constant: -16),
            label.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, multiplier: 0.9)
        ])
        let generator = UINotificationFeedbackGenerator()
        generator.notificationOccurred(.warning)

        UIView.animate(withDuration: 0.25, animations: { label.alpha = 1 }) { _ in
            UIView.animate(withDuration: 0.25, delay: 1.5, options: [], animations: {
                label.alpha = 0
            }) { _ in label.removeFromSuperview() }
        }
        UIAccessibility.post(notification: .announcement, argument: text)
    }

    // 输入时长
    private func inputDuration() -> TimeInterval {
        defer { inputStartTime = nil }
        guard let start = inputStartTime else { return 0 }
        return Date().timeIntervalSince(start)
    }

    // 撤回示例(通常在消息气泡处触发)
    func recallMessage(with id: String) {
        // TODO: 调用后端撤回接口
        analytics.logRecall(messageID: id)
    }
}

// 带内边距的轻提示标签
final class PaddingLabel: UILabel {
    var insets = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12)
    override func drawText(in rect: CGRect) {
        super.drawText(in: rect.inset(by: insets))
    }
    override var intrinsicContentSize: CGSize {
        let s = super.intrinsicContentSize
        return CGSize(width: s.width + insets.left + insets.right, height: s.height + insets.top + insets.bottom)
    }
}

4)埋点示例

struct Analytics {
    func logInputStart() {
        // 可在首次输入时调用
    }

    func logSend(messageLength: Int, inputDuration: TimeInterval) {
        // 发送事件:长度与输入耗时
    }

    func logRecall(messageID: String) {
        // 撤回事件
    }
}

5)本地化字符串(Localizable.strings 示例,中文)

"send_message_placeholder" = "发送消息……";
"access_attach" = "添加附件";
"access_voice_toggle" = "语音输入";
"access_voice_toggle_hint" = "切换语音输入模式";
"access_message_input" = "消息输入框";
"access_send" = "发送";
"error_empty_message" = "内容不能为空";

交互特性

  • 键盘与安全区:
    • 作为 inputAccessoryView,输入栏会随键盘升降,自动避让底部安全区(含 Home 指示器区域)。
  • 文本输入:
    • 文本框最小 1 行,最大 6 行。超出后文本框内部滚动。
    • 发送前会去除首尾空白;空内容禁发(发送按钮置灰不可点)。
    • 回车发送由 sendOnReturn 开关控制:开时拦截“换行”执行发送;关时插入换行。
  • @与/建议:
    • 在光标前识别最后一个以“@”或“/”开头的 token,展示建议面板。
    • 选择建议后替换触发 token 并在后面补空格。
  • 图片粘贴:
    • 发现剪贴板包含图片数组时,触发预览模块(不直接插入为文本)。
  • 无障碍:
    • 为附件、语音、输入框、发送分别设置 accessibilityLabel、Hint 和 .button 等 traits。
    • 通过 inputBar.accessibilityElements 定义朗读顺序(左到右)。
  • 轻提示:
    • 使用半透明 Toast 文本 + 轻触觉反馈;同时通过 UIAccessibility.announcement 广播文案。

使用场景

  • IM/群聊/客服等消息输入场景。
  • 支持基础文本消息、@群成员、斜杠命令的协同类应用。
  • 需要在中国区/海外多语言发布、支持深色模式与无障碍的产品。

限制条件:

  • 语音录制与附件选择器需根据产品策略补充实际实现。
  • 建议面板数据源(成员列表、命令列表)需从业务层获取并缓存。

注意事项

  • 布局与自增长:
    • 将 UITextView.isScrollEnabled 置为 false,通过 sizeThatFits 计算高度,并用高度约束在 min/max 范围内钳制。
    • 输入栏高度变化后调用 invalidateIntrinsicContentSize() 以触发系统更新 inputAccessoryView。
  • 安全区与暗色:
    • 使用系统颜色(systemBackground、secondarySystemBackground、label、secondaryLabel、separator),避免硬编码色值。
  • 国际化:
    • 全部文案经 NSLocalizedString 管理,注意多语言长度差异对布局的影响。
  • 无障碍:
    • 控件点击区域建议不小于 44x44。
    • 为 Toast/错误提示使用 UIAccessibility.announcement,使视障用户可感知。
  • 输入校验与发送:
    • 发送前统一做 trim;空消息/仅空白禁止发送并给出轻提示。
    • sendOnReturn 开关同步设置 returnKeyType,提高可发现性。
  • 性能与状态管理:
    • 建议面板数据量大时做增量过滤与节流(例如 150ms 内防抖)。
    • 埋点的输入时长以“首次非空输入时间点”为起始,发送后重置计时。
  • 粘贴图片:
    • UIPasteboard.images 可能包含大图,预览前考虑压缩与内存占用。
  • 代码组织:
    • 将输入栏(ChatInputBar)与控制器解耦(通过 delegate),便于复用与单元测试。
  • 撤回埋点:
    • 撤回通常发生在消息列表项上,触发时带上 messageID 与原因码(若有),与发送埋点区分。

以上实现遵循 iOS Human Interface Guidelines 的基本建议,覆盖培训所需的主要概念与落地代码。基于此可进一步扩展消息类型、录音与附件流程、富文本等能力。

示例详情

解决的问题

把分散、模糊的iOS界面元素描述,快速转化为可落地、可复用的技术文档。通过统一的结构与清晰的实现指引,帮助团队在原型评审、界面重构、代码走查和新人培训等场景中:

  • 迅速产出高质量技术说明(功能定义、实现思路、交互细节、最佳实践一应俱全)
  • 降低沟通成本,减少返工与遗漏
  • 固化团队规范,保持输出口径一致
  • 将文档产出从“凭经验”转为“按标准”,显著提升交付效率与质量

适用用户

iOS开发工程师

将原型或口头描述快速转为可执行技术文档,直接复用示例进行实现,减少反复沟通与返工。

移动端技术负责人/架构师

统一组件实现规范,批量生成文档用于评审与基线管理,提升团队一致性与可维护性。

产品经理/交互设计师

把交互稿中的组件说明转成技术化文档,提前验证可行性,缩短需求到上线的路径。

特征总结

根据界面描述自动成文,涵盖概述、实现、交互与场景,输出标准化iOS组件说明。
一键生成Swift示例与关键配置,减少查资料与试错时间,开发落地更快更稳。
自动梳理交互行为与状态变化,明确事件响应与边界,避免遗漏与一致性问题。
内置最佳实践与注意事项清单,提前规避性能与易用性风险,降低返工率。
支持按技术深度与输出语言定制,满足评审、开发、培训等不同用途的写作需求。
兼容新旧项目场景,帮助重构界面或补齐文档,迅速对齐团队认知与规范。
模板化输出结构清晰,便于复用与版本管理,让多人协作与代码评审更高效。
从描述到文档全流程自动化,几分钟完成原本数小时工作,显著压缩交付周期。

如何使用购买的提示词模板

1. 直接在外部 Chat 应用中使用

将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。

2. 发布为 API 接口调用

把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。

3. 在 MCP Client 中配置使用

在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。

AI 提示词价格
¥20.00元
先用后买,用好了再付款,超安全!

您购买后可以获得什么

获得完整提示词模板
- 共 632 tokens
- 4 个可调节参数
{ 界面元素描述 } { 输出语言 } { 技术深度 } { 文档用途 }
获得社区贡献内容的使用权
- 精选社区优质案例,助您快速上手提示词
使用提示词兑换券,低至 ¥ 9.9
了解兑换券 →
限时半价

不要错过!

半价获取高级提示词-优惠即将到期

17
:
23
小时
:
59
分钟
:
59