feat(toast): 实现原生Toast系统并优化会员购买错误处理

- 新增iOS原生Toast模块(NativeToastManager),提供毛玻璃风格的Toast展示
- 重构ToastContext为原生模块调用,添加错误边界和回退机制
- 优化会员购买流程的错误处理,使用RevenueCat标准错误码
- 调整购买按钮高度和恢复购买按钮字体大小,改善UI体验
- 移除不必要的延迟和注释代码,提升代码质量
This commit is contained in:
richarjiang
2025-10-28 11:04:34 +08:00
parent db8b50f6d7
commit 71a8bb9740
6 changed files with 707 additions and 140 deletions

View File

@@ -11,6 +11,8 @@
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
792C52592EA880A7002F3F09 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792C52582EA880A7002F3F09 /* StoreKit.framework */; };
792C52622EB05B8F002F3F09 /* NativeToastManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 792C52602EB05B8F002F3F09 /* NativeToastManager.m */; };
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */; };
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
@@ -28,6 +30,8 @@
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
6F6136AA7113B3D210693D88 /* libPods-OutLive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OutLive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
792C52582EA880A7002F3F09 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
792C52602EB05B8F002F3F09 /* NativeToastManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = NativeToastManager.m; path = OutLive/NativeToastManager.m; sourceTree = "<group>"; };
792C52612EB05B8F002F3F09 /* NativeToastManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NativeToastManager.swift; path = OutLive/NativeToastManager.swift; sourceTree = "<group>"; };
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
@@ -94,6 +98,8 @@
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
792C52602EB05B8F002F3F09 /* NativeToastManager.m */,
792C52612EB05B8F002F3F09 /* NativeToastManager.swift */,
79B2CB712E7B954F00B51753 /* HealthKitManager.m */,
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */,
13B07FAE1A68108700A75B9A /* OutLive */,
@@ -359,6 +365,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
792C52622EB05B8F002F3F09 /* NativeToastManager.m in Sources */,
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */,
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */,

View File

@@ -0,0 +1,7 @@
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(NativeToastManager, NSObject)
RCT_EXTERN_METHOD(show:(NSDictionary *)options)
@end

View File

@@ -0,0 +1,514 @@
//
// NativeToastManager.swift
// OutLive
//
// A native toast presenter styled to mirror the app's glassy, elevated UI.
//
import Foundation
import UIKit
@objc(NativeToastManager)
class NativeToastManager: NSObject {
private var currentToastView: ToastView?
private var hideWorkItem: DispatchWorkItem?
@objc
static func requiresMainQueueSetup() -> Bool {
true
}
@objc
func show(_ options: NSDictionary) {
let payload = ToastPayload(dictionary: options)
guard payload.isValid else { return }
DispatchQueue.main.async { [weak self] in
self?.presentToast(with: payload)
}
}
// MARK: - Presentation
private func presentToast(with payload: ToastPayload) {
guard let window = keyWindow else { return }
hideWorkItem?.cancel()
hideWorkItem = nil
if let current = currentToastView {
dismissToast(current, animated: false)
}
let toastView = ToastView(payload: payload)
currentToastView = toastView
window.addSubview(toastView)
NSLayoutConstraint.activate([
toastView.centerXAnchor.constraint(equalTo: window.centerXAnchor),
toastView.topAnchor.constraint(equalTo: window.safeAreaLayoutGuide.topAnchor, constant: 18),
toastView.leadingAnchor.constraint(greaterThanOrEqualTo: window.leadingAnchor, constant: 20),
toastView.trailingAnchor.constraint(lessThanOrEqualTo: window.trailingAnchor, constant: -20)
])
window.layoutIfNeeded()
toastView.alpha = 0
toastView.transform = CGAffineTransform(translationX: 0, y: -26).scaledBy(x: 0.9, y: 0.9)
let notificationFeedback = UINotificationFeedbackGenerator()
let selectionFeedback = UISelectionFeedbackGenerator()
if payload.style.feedbackType != nil {
notificationFeedback.prepare()
} else {
selectionFeedback.prepare()
}
UIView.animate(
withDuration: 0.55,
delay: 0,
usingSpringWithDamping: 0.82,
initialSpringVelocity: 0.6,
options: [.curveEaseOut, .allowUserInteraction]
) {
toastView.alpha = 1
toastView.transform = .identity
} completion: { _ in
if let feedback = payload.style.feedbackType {
notificationFeedback.notificationOccurred(feedback)
} else {
selectionFeedback.selectionChanged()
}
if UIAccessibility.isVoiceOverRunning {
UIAccessibility.post(notification: .announcement, argument: payload.message)
}
}
let workItem = DispatchWorkItem { [weak self, weak toastView] in
guard let toastView, let self else { return }
self.dismissToast(toastView, animated: true)
}
hideWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + payload.duration, execute: workItem)
}
private func dismissToast(_ toastView: ToastView, animated: Bool) {
let cleanup: () -> Void = { [weak self, weak toastView] in
guard let toastView else { return }
toastView.removeFromSuperview()
if self?.currentToastView === toastView {
self?.currentToastView = nil
}
}
guard animated else {
cleanup()
return
}
UIView.animate(
withDuration: 0.28,
delay: 0,
options: [.curveEaseIn, .allowUserInteraction]
) {
toastView.alpha = 0
toastView.transform = CGAffineTransform(translationX: 0, y: -22).scaledBy(x: 0.88, y: 0.88)
} completion: { _ in
cleanup()
}
}
// MARK: - Window Discovery
private var keyWindow: UIWindow? {
if #available(iOS 13.0, *) {
return UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first(where: { $0.activationState == .foregroundActive })?
.windows
.first(where: { $0.isKeyWindow })
} else {
#if !os(tvOS)
return UIApplication.shared.keyWindow
#else
return nil
#endif
}
}
}
// MARK: - Payload & Style Models
private struct ToastPayload {
let message: String
let duration: TimeInterval
let style: ToastStyle
var isValid: Bool {
!message.isEmpty
}
init(dictionary: NSDictionary) {
let primaryMessage = (dictionary["message"] as? String)?.trimmed ?? ""
let fallbackMessage = (dictionary["text1"] as? String)?.trimmed ?? ""
message = primaryMessage.isEmpty ? fallbackMessage : primaryMessage
let durationMs = dictionary["duration"] as? Double ?? 2000
duration = max(1.0, min(durationMs / 1000.0, 10.0))
let type = (dictionary["type"] as? String) ?? "default"
let overrides = ToastStyleOverrides(
backgroundHex: dictionary["backgroundColor"] as? String,
textHex: dictionary["textColor"] as? String,
icon: dictionary["icon"] as? String
)
style = ToastStyle(styleName: type, overrides: overrides)
}
}
private struct ToastStyleOverrides {
let backgroundColor: UIColor?
let textColor: UIColor?
let icon: String?
init(backgroundHex: String?, textHex: String?, icon: String?) {
backgroundColor = backgroundHex.flatMap { UIColor.fromHexString($0) }
textColor = textHex.flatMap { UIColor.fromHexString($0) }
self.icon = icon?.trimmed
}
}
private enum ToastIcon {
case system(name: String)
case glyph(String)
case none
static func from(raw: String?, fallback: ToastIcon) -> ToastIcon {
guard let trimmed = raw?.trimmed, !trimmed.isEmpty else {
return fallback
}
switch trimmed.lowercased() {
case "none", "null":
return .none
default:
break
}
if UIImage(systemName: trimmed) != nil {
return .system(name: trimmed)
}
return .glyph(trimmed)
}
var hasVisual: Bool {
switch self {
case .none:
return false
default:
return true
}
}
}
private struct ToastStyle {
let gradientColors: [UIColor]
let textColor: UIColor
let icon: ToastIcon
let iconTint: UIColor
let iconBackground: UIColor
let borderColor: UIColor
let shadowColor: UIColor
let feedbackType: UINotificationFeedbackGenerator.FeedbackType?
init(styleName: String, overrides: ToastStyleOverrides) {
let baseColor: UIColor
let defaultIcon: ToastIcon
let defaultFeedback: UINotificationFeedbackGenerator.FeedbackType?
switch styleName.lowercased() {
case "success":
baseColor = Palette.success
defaultIcon = .system(name: "checkmark.seal.fill")
defaultFeedback = .success
case "error", "danger", "fail":
baseColor = Palette.error
defaultIcon = .system(name: "xmark.octagon.fill")
defaultFeedback = .error
case "warning":
baseColor = Palette.warning
defaultIcon = .system(name: "exclamationmark.triangle.fill")
defaultFeedback = .warning
case "info":
baseColor = Palette.info
defaultIcon = .system(name: "info.circle.fill")
defaultFeedback = nil
default:
baseColor = Palette.primary
defaultIcon = .system(name: "sparkles")
defaultFeedback = nil
}
let accent = overrides.backgroundColor ?? baseColor
textColor = overrides.textColor ?? .white
iconTint = textColor
icon = ToastIcon.from(raw: overrides.icon, fallback: defaultIcon)
gradientColors = accent.gradientPair
iconBackground = accent.lighten(by: 0.3).withAlphaComponent(0.22)
borderColor = accent.lighten(by: 0.55).withAlphaComponent(0.32)
shadowColor = accent.darken(by: 0.55).withAlphaComponent(0.5)
feedbackType = defaultFeedback
}
}
// MARK: - Toast View
private final class ToastView: UIView {
private let payload: ToastPayload
private let gradientLayer = CAGradientLayer()
private let highlightLayer = CALayer()
private let backgroundView = UIView()
private let iconImageView = UIImageView()
private let iconLabel = UILabel()
init(payload: ToastPayload) {
self.payload = payload
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
accessibilityTraits = .staticText
accessibilityLabel = payload.message
configureShadow()
configureBackground()
configureContent()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = backgroundView.bounds
highlightLayer.frame = backgroundView.bounds
layer.shadowPath = UIBezierPath(
roundedRect: backgroundView.bounds,
cornerRadius: backgroundView.layer.cornerRadius
).cgPath
}
private func configureShadow() {
layer.shadowColor = payload.style.shadowColor.cgColor
layer.shadowOpacity = 0.32
layer.shadowRadius = 18
layer.shadowOffset = CGSize(width: 0, height: 12)
}
private func configureBackground() {
backgroundView.translatesAutoresizingMaskIntoConstraints = false
backgroundView.layer.cornerRadius = 22
backgroundView.layer.masksToBounds = true
backgroundView.layer.borderWidth = 1 / UIScreen.main.scale
backgroundView.layer.borderColor = payload.style.borderColor.cgColor
addSubview(backgroundView)
NSLayoutConstraint.activate([
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
backgroundView.topAnchor.constraint(equalTo: topAnchor),
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
gradientLayer.colors = payload.style.gradientColors.map { $0.cgColor }
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
gradientLayer.locations = [0, 1]
gradientLayer.cornerRadius = backgroundView.layer.cornerRadius
highlightLayer.backgroundColor = UIColor.white.withAlphaComponent(0.06).cgColor
highlightLayer.cornerRadius = backgroundView.layer.cornerRadius
backgroundView.layer.insertSublayer(gradientLayer, at: 0)
backgroundView.layer.insertSublayer(highlightLayer, at: 1)
}
private func configureContent() {
let contentStack = UIStackView()
contentStack.axis = .horizontal
contentStack.alignment = .center
contentStack.spacing = 14
contentStack.translatesAutoresizingMaskIntoConstraints = false
backgroundView.addSubview(contentStack)
NSLayoutConstraint.activate([
contentStack.topAnchor.constraint(equalTo: backgroundView.topAnchor, constant: 16),
contentStack.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor, constant: -16),
contentStack.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor, constant: 20),
contentStack.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor, constant: -20)
])
if payload.style.icon.hasVisual {
let iconContainer = UIView()
iconContainer.translatesAutoresizingMaskIntoConstraints = false
iconContainer.backgroundColor = payload.style.iconBackground
iconContainer.layer.cornerRadius = 18
iconContainer.layer.masksToBounds = false
iconContainer.layer.borderWidth = 1 / UIScreen.main.scale
iconContainer.layer.borderColor = payload.style.iconTint.withAlphaComponent(0.18).cgColor
iconImageView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.tintColor = payload.style.iconTint
iconImageView.contentMode = .scaleAspectFit
iconLabel.translatesAutoresizingMaskIntoConstraints = false
iconLabel.textColor = payload.style.iconTint
iconLabel.font = UIFont.systemFont(ofSize: 16, weight: .bold)
iconLabel.textAlignment = .center
iconContainer.addSubview(iconImageView)
iconContainer.addSubview(iconLabel)
NSLayoutConstraint.activate([
iconContainer.widthAnchor.constraint(equalToConstant: 36),
iconContainer.heightAnchor.constraint(equalToConstant: 36),
iconImageView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
iconImageView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
iconImageView.heightAnchor.constraint(equalToConstant: 20),
iconImageView.widthAnchor.constraint(equalToConstant: 20),
iconLabel.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
iconLabel.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
iconLabel.leadingAnchor.constraint(equalTo: iconContainer.leadingAnchor, constant: 2),
iconLabel.trailingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2)
])
switch payload.style.icon {
case .system(let name):
iconImageView.image = UIImage(systemName: name)
iconImageView.isHidden = false
iconLabel.isHidden = true
iconLabel.text = nil
case .glyph(let glyph):
iconLabel.text = glyph
iconImageView.isHidden = true
iconLabel.isHidden = false
iconImageView.image = nil
case .none:
iconImageView.isHidden = true
iconLabel.isHidden = true
iconImageView.image = nil
iconLabel.text = nil
}
contentStack.addArrangedSubview(iconContainer)
}
let messageLabel = UILabel()
messageLabel.translatesAutoresizingMaskIntoConstraints = false
messageLabel.text = payload.message
messageLabel.textColor = payload.style.textColor
messageLabel.numberOfLines = 0
messageLabel.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(
for: UIFont.systemFont(ofSize: 15, weight: .semibold)
)
messageLabel.adjustsFontForContentSizeCategory = true
messageLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
messageLabel.setContentCompressionResistancePriority(.required, for: .vertical)
messageLabel.setContentHuggingPriority(.required, for: .vertical)
contentStack.addArrangedSubview(messageLabel)
}
}
// MARK: - Palette
private enum Palette {
static let primary = UIColor(hex: 0x7A5AF8)
static let success = UIColor(hex: 0x19B36E)
static let error = UIColor(hex: 0xF95555)
static let warning = UIColor(hex: 0xFFAD42)
static let info = UIColor(hex: 0x6938EF)
}
// MARK: - Helpers
private extension UIColor {
convenience init(hex: UInt32, alpha: CGFloat = 1.0) {
let red = CGFloat((hex & 0xFF0000) >> 16) / 255.0
let green = CGFloat((hex & 0x00FF00) >> 8) / 255.0
let blue = CGFloat(hex & 0x0000FF) / 255.0
self.init(red: red, green: green, blue: blue, alpha: alpha)
}
static func fromHexString(_ hexString: String) -> UIColor? {
var string = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
if string.hasPrefix("#") {
string.removeFirst()
} else if string.hasPrefix("0X") {
string.removeFirst(2)
}
guard string.count == 6 || string.count == 8 else { return nil }
var hexValue: UInt64 = 0
guard Scanner(string: string).scanHexInt64(&hexValue) else { return nil }
if string.count == 8 {
let alpha = CGFloat(hexValue & 0xFF) / 255.0
let rgb = UInt32(hexValue >> 8)
return UIColor(hex: rgb, alpha: alpha)
} else {
return UIColor(hex: UInt32(hexValue))
}
}
func mix(with color: UIColor, amount: CGFloat) -> UIColor {
let amount = max(0, min(1, amount))
var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0
var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0
guard getRed(&r1, green: &g1, blue: &b1, alpha: &a1),
color.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) else {
return self
}
let red = r1 * (1 - amount) + r2 * amount
let green = g1 * (1 - amount) + g2 * amount
let blue = b1 * (1 - amount) + b2 * amount
let alpha = a1 * (1 - amount) + a2 * amount
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
}
func lighten(by amount: CGFloat) -> UIColor {
mix(with: .white, amount: amount)
}
func darken(by amount: CGFloat) -> UIColor {
mix(with: .black, amount: amount)
}
var gradientPair: [UIColor] {
[lighten(by: 0.18), darken(by: 0.12)]
}
}
private extension String {
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}