添加语音websocket等,还没测试
This commit is contained in:
16
keyBoard/Class/AiTalk/V/KBAICommentView.h
Normal file
16
keyBoard/Class/AiTalk/V/KBAICommentView.h
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// KBAICommentView.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/16.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBAICommentView : UIView
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
20
keyBoard/Class/AiTalk/V/KBAICommentView.m
Normal file
20
keyBoard/Class/AiTalk/V/KBAICommentView.m
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// KBAICommentView.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/16.
|
||||
//
|
||||
|
||||
#import "KBAICommentView.h"
|
||||
|
||||
@implementation KBAICommentView
|
||||
|
||||
/*
|
||||
// Only override drawRect: if you perform custom drawing.
|
||||
// An empty implementation adversely affects performance during animation.
|
||||
- (void)drawRect:(CGRect)rect {
|
||||
// Drawing code
|
||||
}
|
||||
*/
|
||||
|
||||
@end
|
||||
54
keyBoard/Class/AiTalk/V/KBAiChatView.h
Normal file
54
keyBoard/Class/AiTalk/V/KBAiChatView.h
Normal file
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// KBAiChatView.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 消息类型
|
||||
typedef NS_ENUM(NSInteger, KBAiChatMessageType) {
|
||||
KBAiChatMessageTypeUser, // 用户消息
|
||||
KBAiChatMessageTypeAssistant // AI 回复
|
||||
};
|
||||
|
||||
/// 聊天消息模型
|
||||
@interface KBAiChatMessage : NSObject
|
||||
@property(nonatomic, assign) KBAiChatMessageType type;
|
||||
@property(nonatomic, copy) NSString *text;
|
||||
@property(nonatomic, assign) BOOL isComplete; // 是否完成(用于打字机效果)
|
||||
+ (instancetype)userMessageWithText:(NSString *)text;
|
||||
+ (instancetype)assistantMessageWithText:(NSString *)text;
|
||||
@end
|
||||
|
||||
/// 聊天视图
|
||||
/// 显示用户消息和 AI 回复的气泡列表
|
||||
@interface KBAiChatView : UIView
|
||||
|
||||
/// 添加用户消息
|
||||
/// @param text 消息文本
|
||||
- (void)addUserMessage:(NSString *)text;
|
||||
|
||||
/// 添加 AI 消息
|
||||
/// @param text 消息文本
|
||||
- (void)addAssistantMessage:(NSString *)text;
|
||||
|
||||
/// 更新最后一条 AI 消息(用于打字机效果)
|
||||
/// @param text 当前可见文本
|
||||
- (void)updateLastAssistantMessage:(NSString *)text;
|
||||
|
||||
/// 标记最后一条 AI 消息完成
|
||||
- (void)markLastAssistantMessageComplete;
|
||||
|
||||
/// 清空所有消息
|
||||
- (void)clearMessages;
|
||||
|
||||
/// 滚动到底部
|
||||
- (void)scrollToBottom;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
296
keyBoard/Class/AiTalk/V/KBAiChatView.m
Normal file
296
keyBoard/Class/AiTalk/V/KBAiChatView.m
Normal file
@@ -0,0 +1,296 @@
|
||||
//
|
||||
// KBAiChatView.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "KBAiChatView.h"
|
||||
|
||||
#pragma mark - KBAiChatMessage
|
||||
|
||||
@implementation KBAiChatMessage
|
||||
|
||||
+ (instancetype)userMessageWithText:(NSString *)text {
|
||||
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
|
||||
message.type = KBAiChatMessageTypeUser;
|
||||
message.text = text;
|
||||
message.isComplete = YES;
|
||||
return message;
|
||||
}
|
||||
|
||||
+ (instancetype)assistantMessageWithText:(NSString *)text {
|
||||
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
|
||||
message.type = KBAiChatMessageTypeAssistant;
|
||||
message.text = text;
|
||||
message.isComplete = NO;
|
||||
return message;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - KBAiChatBubbleCell
|
||||
|
||||
@interface KBAiChatBubbleCell : UITableViewCell
|
||||
@property(nonatomic, strong) UIView *bubbleView;
|
||||
@property(nonatomic, strong) UILabel *messageLabel;
|
||||
@property(nonatomic, assign) KBAiChatMessageType messageType;
|
||||
@end
|
||||
|
||||
@implementation KBAiChatBubbleCell
|
||||
|
||||
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||||
reuseIdentifier:(NSString *)reuseIdentifier {
|
||||
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||
if (self) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
// 气泡视图
|
||||
self.bubbleView = [[UIView alloc] init];
|
||||
self.bubbleView.layer.cornerRadius = 16;
|
||||
self.bubbleView.layer.masksToBounds = YES;
|
||||
self.bubbleView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.contentView addSubview:self.bubbleView];
|
||||
|
||||
// 消息标签
|
||||
self.messageLabel = [[UILabel alloc] init];
|
||||
self.messageLabel.numberOfLines = 0;
|
||||
self.messageLabel.font = [UIFont systemFontOfSize:16];
|
||||
self.messageLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.bubbleView addSubview:self.messageLabel];
|
||||
|
||||
// 消息标签约束
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.messageLabel.topAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.topAnchor
|
||||
constant:10],
|
||||
[self.messageLabel.bottomAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.bottomAnchor
|
||||
constant:-10],
|
||||
[self.messageLabel.leadingAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.leadingAnchor
|
||||
constant:12],
|
||||
[self.messageLabel.trailingAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.trailingAnchor
|
||||
constant:-12],
|
||||
]];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)configureWithMessage:(KBAiChatMessage *)message {
|
||||
self.messageLabel.text = message.text;
|
||||
self.messageType = message.type;
|
||||
|
||||
// 移除旧约束
|
||||
for (NSLayoutConstraint *constraint in self.bubbleView.constraints) {
|
||||
if (constraint.firstAttribute == NSLayoutAttributeWidth) {
|
||||
constraint.active = NO;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据消息类型设置样式
|
||||
if (message.type == KBAiChatMessageTypeUser) {
|
||||
// 用户消息:右对齐,主题色背景
|
||||
self.bubbleView.backgroundColor = [UIColor systemBlueColor];
|
||||
self.messageLabel.textColor = [UIColor whiteColor];
|
||||
|
||||
[NSLayoutConstraint deactivateConstraints:self.bubbleView.constraints];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.bubbleView.topAnchor
|
||||
constraintEqualToAnchor:self.contentView.topAnchor
|
||||
constant:4],
|
||||
[self.bubbleView.bottomAnchor
|
||||
constraintEqualToAnchor:self.contentView.bottomAnchor
|
||||
constant:-4],
|
||||
[self.bubbleView.trailingAnchor
|
||||
constraintEqualToAnchor:self.contentView.trailingAnchor
|
||||
constant:-16],
|
||||
[self.bubbleView.widthAnchor
|
||||
constraintLessThanOrEqualToAnchor:self.contentView.widthAnchor
|
||||
multiplier:0.75],
|
||||
|
||||
[self.messageLabel.topAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.topAnchor
|
||||
constant:10],
|
||||
[self.messageLabel.bottomAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.bottomAnchor
|
||||
constant:-10],
|
||||
[self.messageLabel.leadingAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.leadingAnchor
|
||||
constant:12],
|
||||
[self.messageLabel.trailingAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.trailingAnchor
|
||||
constant:-12],
|
||||
]];
|
||||
} else {
|
||||
// AI 消息:左对齐,浅灰色背景
|
||||
self.bubbleView.backgroundColor = [UIColor systemGray5Color];
|
||||
self.messageLabel.textColor = [UIColor labelColor];
|
||||
|
||||
[NSLayoutConstraint deactivateConstraints:self.bubbleView.constraints];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.bubbleView.topAnchor
|
||||
constraintEqualToAnchor:self.contentView.topAnchor
|
||||
constant:4],
|
||||
[self.bubbleView.bottomAnchor
|
||||
constraintEqualToAnchor:self.contentView.bottomAnchor
|
||||
constant:-4],
|
||||
[self.bubbleView.leadingAnchor
|
||||
constraintEqualToAnchor:self.contentView.leadingAnchor
|
||||
constant:16],
|
||||
[self.bubbleView.widthAnchor
|
||||
constraintLessThanOrEqualToAnchor:self.contentView.widthAnchor
|
||||
multiplier:0.75],
|
||||
|
||||
[self.messageLabel.topAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.topAnchor
|
||||
constant:10],
|
||||
[self.messageLabel.bottomAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.bottomAnchor
|
||||
constant:-10],
|
||||
[self.messageLabel.leadingAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.leadingAnchor
|
||||
constant:12],
|
||||
[self.messageLabel.trailingAnchor
|
||||
constraintEqualToAnchor:self.bubbleView.trailingAnchor
|
||||
constant:-12],
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - KBAiChatView
|
||||
|
||||
@interface KBAiChatView () <UITableViewDataSource, UITableViewDelegate>
|
||||
@property(nonatomic, strong) UITableView *tableView;
|
||||
@property(nonatomic, strong) NSMutableArray<KBAiChatMessage *> *messages;
|
||||
@end
|
||||
|
||||
@implementation KBAiChatView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
self = [super initWithFrame:frame];
|
||||
if (self) {
|
||||
[self setup];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithCoder:(NSCoder *)coder {
|
||||
self = [super initWithCoder:coder];
|
||||
if (self) {
|
||||
[self setup];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setup {
|
||||
self.messages = [[NSMutableArray alloc] init];
|
||||
|
||||
self.tableView = [[UITableView alloc] initWithFrame:self.bounds
|
||||
style:UITableViewStylePlain];
|
||||
self.tableView.autoresizingMask =
|
||||
UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
self.tableView.dataSource = self;
|
||||
self.tableView.delegate = self;
|
||||
self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||
self.tableView.backgroundColor = [UIColor clearColor];
|
||||
self.tableView.estimatedRowHeight = 60;
|
||||
self.tableView.rowHeight = UITableViewAutomaticDimension;
|
||||
[self.tableView registerClass:[KBAiChatBubbleCell class]
|
||||
forCellReuseIdentifier:@"ChatCell"];
|
||||
[self addSubview:self.tableView];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)addUserMessage:(NSString *)text {
|
||||
KBAiChatMessage *message = [KBAiChatMessage userMessageWithText:text];
|
||||
[self.messages addObject:message];
|
||||
|
||||
[self.tableView reloadData];
|
||||
[self scrollToBottom];
|
||||
}
|
||||
|
||||
- (void)addAssistantMessage:(NSString *)text {
|
||||
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text];
|
||||
[self.messages addObject:message];
|
||||
|
||||
[self.tableView reloadData];
|
||||
[self scrollToBottom];
|
||||
}
|
||||
|
||||
- (void)updateLastAssistantMessage:(NSString *)text {
|
||||
// 查找最后一条 AI 消息
|
||||
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||
KBAiChatMessage *message = self.messages[i];
|
||||
if (message.type == KBAiChatMessageTypeAssistant && !message.isComplete) {
|
||||
message.text = text;
|
||||
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||
[self.tableView reloadRowsAtIndexPaths:@[ indexPath ]
|
||||
withRowAnimation:UITableViewRowAnimationNone];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到,添加新消息
|
||||
[self addAssistantMessage:text];
|
||||
}
|
||||
|
||||
- (void)markLastAssistantMessageComplete {
|
||||
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||
KBAiChatMessage *message = self.messages[i];
|
||||
if (message.type == KBAiChatMessageTypeAssistant) {
|
||||
message.isComplete = YES;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)clearMessages {
|
||||
[self.messages removeAllObjects];
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
- (void)scrollToBottom {
|
||||
if (self.messages.count == 0)
|
||||
return;
|
||||
|
||||
NSIndexPath *lastIndexPath =
|
||||
[NSIndexPath indexPathForRow:self.messages.count - 1 inSection:0];
|
||||
[self.tableView scrollToRowAtIndexPath:lastIndexPath
|
||||
atScrollPosition:UITableViewScrollPositionBottom
|
||||
animated:YES];
|
||||
}
|
||||
|
||||
#pragma mark - UITableViewDataSource
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tableView
|
||||
numberOfRowsInSection:(NSInteger)section {
|
||||
return self.messages.count;
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tableView
|
||||
cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
KBAiChatBubbleCell *cell =
|
||||
[tableView dequeueReusableCellWithIdentifier:@"ChatCell"
|
||||
forIndexPath:indexPath];
|
||||
|
||||
KBAiChatMessage *message = self.messages[indexPath.row];
|
||||
[cell configureWithMessage:message];
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
#pragma mark - UITableViewDelegate
|
||||
|
||||
- (CGFloat)tableView:(UITableView *)tableView
|
||||
estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
return 60;
|
||||
}
|
||||
|
||||
@end
|
||||
56
keyBoard/Class/AiTalk/V/KBAiRecordButton.h
Normal file
56
keyBoard/Class/AiTalk/V/KBAiRecordButton.h
Normal file
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// KBAiRecordButton.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 录音按钮状态
|
||||
typedef NS_ENUM(NSInteger, KBAiRecordButtonState) {
|
||||
KBAiRecordButtonStateNormal, // 正常状态
|
||||
KBAiRecordButtonStateRecording, // 录音中
|
||||
KBAiRecordButtonStateDisabled // 禁用
|
||||
};
|
||||
|
||||
@class KBAiRecordButton;
|
||||
|
||||
/// 录音按钮代理
|
||||
@protocol KBAiRecordButtonDelegate <NSObject>
|
||||
@optional
|
||||
/// 开始按下
|
||||
- (void)recordButtonDidBeginPress:(KBAiRecordButton *)button;
|
||||
/// 结束按下
|
||||
- (void)recordButtonDidEndPress:(KBAiRecordButton *)button;
|
||||
/// 按下被取消(如手指滑出)
|
||||
- (void)recordButtonDidCancelPress:(KBAiRecordButton *)button;
|
||||
@end
|
||||
|
||||
/// 按住说话按钮
|
||||
/// 支持长按手势和波形动画
|
||||
@interface KBAiRecordButton : UIView
|
||||
|
||||
@property(nonatomic, weak) id<KBAiRecordButtonDelegate> delegate;
|
||||
|
||||
/// 当前状态
|
||||
@property(nonatomic, assign) KBAiRecordButtonState state;
|
||||
|
||||
/// 按钮标题(正常状态)
|
||||
@property(nonatomic, copy) NSString *normalTitle;
|
||||
|
||||
/// 按钮标题(录音状态)
|
||||
@property(nonatomic, copy) NSString *recordingTitle;
|
||||
|
||||
/// 主色调
|
||||
@property(nonatomic, strong) UIColor *tintColor;
|
||||
|
||||
/// 更新音量(用于波形动画)
|
||||
/// @param rms 音量 RMS 值 (0.0 - 1.0)
|
||||
- (void)updateVolumeRMS:(float)rms;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
248
keyBoard/Class/AiTalk/V/KBAiRecordButton.m
Normal file
248
keyBoard/Class/AiTalk/V/KBAiRecordButton.m
Normal file
@@ -0,0 +1,248 @@
|
||||
//
|
||||
// KBAiRecordButton.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "KBAiRecordButton.h"
|
||||
#import "KBAiWaveformView.h"
|
||||
|
||||
@interface KBAiRecordButton ()
|
||||
|
||||
@property(nonatomic, strong) UIView *backgroundView;
|
||||
@property(nonatomic, strong) UILabel *titleLabel;
|
||||
@property(nonatomic, strong) KBAiWaveformView *waveformView;
|
||||
@property(nonatomic, strong) UIImageView *micIconView;
|
||||
@property(nonatomic, assign) BOOL isPressing;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBAiRecordButton
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
self = [super initWithFrame:frame];
|
||||
if (self) {
|
||||
[self setup];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithCoder:(NSCoder *)coder {
|
||||
self = [super initWithCoder:coder];
|
||||
if (self) {
|
||||
[self setup];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setup {
|
||||
_state = KBAiRecordButtonStateNormal;
|
||||
_normalTitle = @"按住说话";
|
||||
_recordingTitle = @"松开结束";
|
||||
_tintColor = [UIColor systemBlueColor];
|
||||
|
||||
// 背景视图
|
||||
self.backgroundView = [[UIView alloc] init];
|
||||
self.backgroundView.backgroundColor = [UIColor systemGray6Color];
|
||||
self.backgroundView.layer.cornerRadius = 25;
|
||||
self.backgroundView.layer.masksToBounds = YES;
|
||||
self.backgroundView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:self.backgroundView];
|
||||
|
||||
// 麦克风图标
|
||||
self.micIconView = [[UIImageView alloc] init];
|
||||
self.micIconView.image = [UIImage systemImageNamed:@"mic.fill"];
|
||||
self.micIconView.tintColor = self.tintColor;
|
||||
self.micIconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
self.micIconView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.backgroundView addSubview:self.micIconView];
|
||||
|
||||
// 标题标签
|
||||
self.titleLabel = [[UILabel alloc] init];
|
||||
self.titleLabel.text = self.normalTitle;
|
||||
self.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium];
|
||||
self.titleLabel.textColor = [UIColor labelColor];
|
||||
self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.backgroundView addSubview:self.titleLabel];
|
||||
|
||||
// 波形视图(录音时显示)
|
||||
self.waveformView = [[KBAiWaveformView alloc] init];
|
||||
self.waveformView.waveColor = self.tintColor;
|
||||
self.waveformView.alpha = 0;
|
||||
self.waveformView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.backgroundView addSubview:self.waveformView];
|
||||
|
||||
// 布局约束
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.backgroundView.topAnchor constraintEqualToAnchor:self.topAnchor],
|
||||
[self.backgroundView.bottomAnchor
|
||||
constraintEqualToAnchor:self.bottomAnchor],
|
||||
[self.backgroundView.leadingAnchor
|
||||
constraintEqualToAnchor:self.leadingAnchor],
|
||||
[self.backgroundView.trailingAnchor
|
||||
constraintEqualToAnchor:self.trailingAnchor],
|
||||
|
||||
[self.micIconView.leadingAnchor
|
||||
constraintEqualToAnchor:self.backgroundView.leadingAnchor
|
||||
constant:20],
|
||||
[self.micIconView.centerYAnchor
|
||||
constraintEqualToAnchor:self.backgroundView.centerYAnchor],
|
||||
[self.micIconView.widthAnchor constraintEqualToConstant:24],
|
||||
[self.micIconView.heightAnchor constraintEqualToConstant:24],
|
||||
|
||||
[self.titleLabel.leadingAnchor
|
||||
constraintEqualToAnchor:self.micIconView.trailingAnchor
|
||||
constant:12],
|
||||
[self.titleLabel.centerYAnchor
|
||||
constraintEqualToAnchor:self.backgroundView.centerYAnchor],
|
||||
|
||||
[self.waveformView.trailingAnchor
|
||||
constraintEqualToAnchor:self.backgroundView.trailingAnchor
|
||||
constant:-20],
|
||||
[self.waveformView.centerYAnchor
|
||||
constraintEqualToAnchor:self.backgroundView.centerYAnchor],
|
||||
[self.waveformView.widthAnchor constraintEqualToConstant:60],
|
||||
[self.waveformView.heightAnchor constraintEqualToConstant:30],
|
||||
]];
|
||||
|
||||
// 添加手势
|
||||
UILongPressGestureRecognizer *longPress =
|
||||
[[UILongPressGestureRecognizer alloc]
|
||||
initWithTarget:self
|
||||
action:@selector(handleLongPress:)];
|
||||
longPress.minimumPressDuration = 0.05;
|
||||
[self addGestureRecognizer:longPress];
|
||||
}
|
||||
|
||||
#pragma mark - Setters
|
||||
|
||||
- (void)setState:(KBAiRecordButtonState)state {
|
||||
if (_state == state)
|
||||
return;
|
||||
_state = state;
|
||||
|
||||
[self updateAppearance];
|
||||
}
|
||||
|
||||
- (void)setTintColor:(UIColor *)tintColor {
|
||||
_tintColor = tintColor;
|
||||
self.micIconView.tintColor = tintColor;
|
||||
self.waveformView.waveColor = tintColor;
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)updateVolumeRMS:(float)rms {
|
||||
[self.waveformView updateWithRMS:rms];
|
||||
}
|
||||
|
||||
#pragma mark - Private Methods
|
||||
|
||||
- (void)updateAppearance {
|
||||
switch (self.state) {
|
||||
case KBAiRecordButtonStateNormal:
|
||||
self.titleLabel.text = self.normalTitle;
|
||||
self.backgroundView.backgroundColor = [UIColor systemGray6Color];
|
||||
self.micIconView.alpha = 1;
|
||||
self.waveformView.alpha = 0;
|
||||
[self.waveformView stopAnimation];
|
||||
break;
|
||||
|
||||
case KBAiRecordButtonStateRecording:
|
||||
self.titleLabel.text = self.recordingTitle;
|
||||
self.backgroundView.backgroundColor =
|
||||
[self.tintColor colorWithAlphaComponent:0.15];
|
||||
self.micIconView.alpha = 1;
|
||||
self.waveformView.alpha = 1;
|
||||
[self.waveformView startIdleAnimation];
|
||||
break;
|
||||
|
||||
case KBAiRecordButtonStateDisabled:
|
||||
self.titleLabel.text = self.normalTitle;
|
||||
self.backgroundView.backgroundColor = [UIColor systemGray5Color];
|
||||
self.alpha = 0.5;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)handleLongPress:(UILongPressGestureRecognizer *)gesture {
|
||||
if (self.state == KBAiRecordButtonStateDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
CGPoint location = [gesture locationInView:self];
|
||||
BOOL isInside = CGRectContainsPoint(self.bounds, location);
|
||||
|
||||
switch (gesture.state) {
|
||||
case UIGestureRecognizerStateBegan:
|
||||
self.isPressing = YES;
|
||||
[self animateScale:0.95];
|
||||
self.state = KBAiRecordButtonStateRecording;
|
||||
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(recordButtonDidBeginPress:)]) {
|
||||
[self.delegate recordButtonDidBeginPress:self];
|
||||
}
|
||||
break;
|
||||
|
||||
case UIGestureRecognizerStateChanged:
|
||||
if (!isInside && self.isPressing) {
|
||||
// 手指滑出
|
||||
[self animateScale:1.0];
|
||||
} else if (isInside && self.isPressing) {
|
||||
// 手指滑回
|
||||
[self animateScale:0.95];
|
||||
}
|
||||
break;
|
||||
|
||||
case UIGestureRecognizerStateEnded:
|
||||
if (self.isPressing) {
|
||||
self.isPressing = NO;
|
||||
[self animateScale:1.0];
|
||||
self.state = KBAiRecordButtonStateNormal;
|
||||
[self.waveformView reset];
|
||||
|
||||
if (isInside) {
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(recordButtonDidEndPress:)]) {
|
||||
[self.delegate recordButtonDidEndPress:self];
|
||||
}
|
||||
} else {
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(recordButtonDidCancelPress:)]) {
|
||||
[self.delegate recordButtonDidCancelPress:self];
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case UIGestureRecognizerStateCancelled:
|
||||
case UIGestureRecognizerStateFailed:
|
||||
if (self.isPressing) {
|
||||
self.isPressing = NO;
|
||||
[self animateScale:1.0];
|
||||
self.state = KBAiRecordButtonStateNormal;
|
||||
[self.waveformView reset];
|
||||
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(recordButtonDidCancelPress:)]) {
|
||||
[self.delegate recordButtonDidCancelPress:self];
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)animateScale:(CGFloat)scale {
|
||||
[UIView animateWithDuration:0.15
|
||||
animations:^{
|
||||
self.backgroundView.transform =
|
||||
CGAffineTransformMakeScale(scale, scale);
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
43
keyBoard/Class/AiTalk/V/KBAiWaveformView.h
Normal file
43
keyBoard/Class/AiTalk/V/KBAiWaveformView.h
Normal file
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// KBAiWaveformView.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 波形动画视图
|
||||
/// 根据音量 RMS 值实时显示波形动画
|
||||
@interface KBAiWaveformView : UIView
|
||||
|
||||
/// 波形颜色
|
||||
@property(nonatomic, strong) UIColor *waveColor;
|
||||
|
||||
/// 波形条数量
|
||||
@property(nonatomic, assign) NSInteger barCount;
|
||||
|
||||
/// 波形条宽度
|
||||
@property(nonatomic, assign) CGFloat barWidth;
|
||||
|
||||
/// 波形条间距
|
||||
@property(nonatomic, assign) CGFloat barSpacing;
|
||||
|
||||
/// 更新音量值
|
||||
/// @param rms 音量 RMS 值 (0.0 - 1.0)
|
||||
- (void)updateWithRMS:(float)rms;
|
||||
|
||||
/// 开始动画(空闲波动)
|
||||
- (void)startIdleAnimation;
|
||||
|
||||
/// 停止动画
|
||||
- (void)stopAnimation;
|
||||
|
||||
/// 重置波形
|
||||
- (void)reset;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
163
keyBoard/Class/AiTalk/V/KBAiWaveformView.m
Normal file
163
keyBoard/Class/AiTalk/V/KBAiWaveformView.m
Normal file
@@ -0,0 +1,163 @@
|
||||
//
|
||||
// KBAiWaveformView.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "KBAiWaveformView.h"
|
||||
|
||||
@interface KBAiWaveformView ()
|
||||
@property(nonatomic, strong) NSMutableArray<CAShapeLayer *> *barLayers;
|
||||
@property(nonatomic, strong) NSMutableArray<NSNumber *> *barHeights;
|
||||
@property(nonatomic, strong) CADisplayLink *displayLink;
|
||||
@property(nonatomic, assign) float currentRMS;
|
||||
@property(nonatomic, assign) float targetRMS;
|
||||
@property(nonatomic, assign) BOOL isAnimating;
|
||||
@end
|
||||
|
||||
@implementation KBAiWaveformView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
self = [super initWithFrame:frame];
|
||||
if (self) {
|
||||
[self setup];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithCoder:(NSCoder *)coder {
|
||||
self = [super initWithCoder:coder];
|
||||
if (self) {
|
||||
[self setup];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setup {
|
||||
_waveColor = [UIColor systemBlueColor];
|
||||
_barCount = 5;
|
||||
_barWidth = 4;
|
||||
_barSpacing = 3;
|
||||
_barLayers = [[NSMutableArray alloc] init];
|
||||
_barHeights = [[NSMutableArray alloc] init];
|
||||
_currentRMS = 0;
|
||||
_targetRMS = 0;
|
||||
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
[super layoutSubviews];
|
||||
[self setupBars];
|
||||
}
|
||||
|
||||
- (void)setupBars {
|
||||
// 移除旧的图层
|
||||
for (CAShapeLayer *layer in self.barLayers) {
|
||||
[layer removeFromSuperlayer];
|
||||
}
|
||||
[self.barLayers removeAllObjects];
|
||||
[self.barHeights removeAllObjects];
|
||||
|
||||
// 计算总宽度
|
||||
CGFloat totalWidth =
|
||||
self.barCount * self.barWidth + (self.barCount - 1) * self.barSpacing;
|
||||
CGFloat startX = (self.bounds.size.width - totalWidth) / 2;
|
||||
CGFloat maxHeight = self.bounds.size.height;
|
||||
CGFloat minHeight = maxHeight * 0.2;
|
||||
|
||||
for (NSInteger i = 0; i < self.barCount; i++) {
|
||||
CAShapeLayer *barLayer = [CAShapeLayer layer];
|
||||
barLayer.fillColor = self.waveColor.CGColor;
|
||||
barLayer.cornerRadius = self.barWidth / 2;
|
||||
|
||||
CGFloat x = startX + i * (self.barWidth + self.barSpacing);
|
||||
CGFloat height = minHeight;
|
||||
CGFloat y = (maxHeight - height) / 2;
|
||||
|
||||
barLayer.frame = CGRectMake(x, y, self.barWidth, height);
|
||||
barLayer.backgroundColor = self.waveColor.CGColor;
|
||||
|
||||
[self.layer addSublayer:barLayer];
|
||||
[self.barLayers addObject:barLayer];
|
||||
[self.barHeights addObject:@(height)];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)updateWithRMS:(float)rms {
|
||||
self.targetRMS = MIN(MAX(rms, 0), 1);
|
||||
}
|
||||
|
||||
- (void)startIdleAnimation {
|
||||
if (self.isAnimating)
|
||||
return;
|
||||
|
||||
self.isAnimating = YES;
|
||||
self.displayLink =
|
||||
[CADisplayLink displayLinkWithTarget:self
|
||||
selector:@selector(updateAnimation)];
|
||||
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop]
|
||||
forMode:NSRunLoopCommonModes];
|
||||
}
|
||||
|
||||
- (void)stopAnimation {
|
||||
self.isAnimating = NO;
|
||||
[self.displayLink invalidate];
|
||||
self.displayLink = nil;
|
||||
}
|
||||
|
||||
- (void)reset {
|
||||
self.currentRMS = 0;
|
||||
self.targetRMS = 0;
|
||||
[self updateBarsWithRMS:0];
|
||||
}
|
||||
|
||||
#pragma mark - Animation
|
||||
|
||||
- (void)updateAnimation {
|
||||
// 平滑过渡到目标 RMS
|
||||
CGFloat smoothing = 0.3;
|
||||
self.currentRMS =
|
||||
self.currentRMS + (self.targetRMS - self.currentRMS) * smoothing;
|
||||
|
||||
[self updateBarsWithRMS:self.currentRMS];
|
||||
}
|
||||
|
||||
- (void)updateBarsWithRMS:(float)rms {
|
||||
CGFloat maxHeight = self.bounds.size.height;
|
||||
CGFloat minHeight = maxHeight * 0.2;
|
||||
CGFloat range = maxHeight - minHeight;
|
||||
|
||||
// 为每个条添加略微不同的高度和相位
|
||||
NSTimeInterval time = CACurrentMediaTime();
|
||||
|
||||
for (NSInteger i = 0; i < self.barLayers.count; i++) {
|
||||
CAShapeLayer *layer = self.barLayers[i];
|
||||
|
||||
// 添加基于时间的波动效果
|
||||
CGFloat phase = (CGFloat)i / self.barLayers.count * M_PI * 2;
|
||||
CGFloat wave = sin(time * 3 + phase) * 0.3 + 0.7; // 0.4 - 1.0
|
||||
|
||||
// 计算高度
|
||||
CGFloat heightFactor = rms * wave;
|
||||
CGFloat height = minHeight + range * heightFactor;
|
||||
height = MAX(minHeight, MIN(maxHeight, height));
|
||||
|
||||
// 更新位置
|
||||
CGFloat y = (maxHeight - height) / 2;
|
||||
|
||||
[CATransaction begin];
|
||||
[CATransaction setDisableActions:YES];
|
||||
layer.frame = CGRectMake(layer.frame.origin.x, y, self.barWidth, height);
|
||||
[CATransaction commit];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self stopAnimation];
|
||||
}
|
||||
|
||||
@end
|
||||
Reference in New Issue
Block a user