Files
keyboard/keyBoard/Class/AiTalk/V/KBAICommentView.m
2026-01-16 15:55:08 +08:00

616 lines
20 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// KBAICommentView.m
// keyBoard
//
// Created by Mac on 2026/1/16.
//
#import "KBAICommentView.h"
#import "KBAICommentFooterView.h"
#import "KBAICommentHeaderView.h"
#import "KBAICommentInputView.h"
#import "KBAICommentModel.h"
#import "KBAIReplyCell.h"
#import "KBAIReplyModel.h"
#import <MJExtension/MJExtension.h>
#import <Masonry/Masonry.h>
static NSString *const kCommentHeaderIdentifier = @"CommentHeader";
static NSString *const kReplyCellIdentifier = @"ReplyCell";
static NSString *const kCommentFooterIdentifier = @"CommentFooter";
/// 每次展开的回复数量
static NSInteger const kRepliesLoadCount = 3;
@interface KBAICommentView () <UITableViewDataSource, UITableViewDelegate>
@property(nonatomic, strong) UIView *headerView;
@property(nonatomic, strong) UILabel *titleLabel;
@property(nonatomic, strong) UIButton *closeButton;
@property(nonatomic, strong) UITableView *tableView;
@property(nonatomic, strong) KBAICommentInputView *inputView;
@property(nonatomic, strong) NSMutableArray<KBAICommentModel *> *comments;
@property(nonatomic, assign) NSInteger totalCommentCount;
/// 键盘高度
@property(nonatomic, assign) CGFloat keyboardHeight;
/// 输入框底部约束
@property(nonatomic, strong) MASConstraint *inputBottomConstraint;
@end
@implementation KBAICommentView
#pragma mark - Lifecycle
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
self.comments = [NSMutableArray array];
[self setupUI];
[self setupKeyboardObservers];
[self loadComments];
}
return self;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - UI Setup
- (void)setupUI {
self.backgroundColor = [UIColor whiteColor];
self.layer.cornerRadius = 12;
self.layer.maskedCorners = kCALayerMinXMinYCorner | kCALayerMaxXMinYCorner;
self.clipsToBounds = YES;
[self addSubview:self.headerView];
[self.headerView addSubview:self.titleLabel];
[self.headerView addSubview:self.closeButton];
[self addSubview:self.tableView];
[self addSubview:self.inputView];
[self.headerView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.left.right.equalTo(self);
make.height.mas_equalTo(50);
}];
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.headerView);
}];
[self.closeButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.headerView).offset(-16);
make.centerY.equalTo(self.headerView);
make.width.height.mas_equalTo(30);
}];
[self.inputView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self);
make.height.mas_equalTo(50);
self.inputBottomConstraint = make.bottom.equalTo(self);
}];
[self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.headerView.mas_bottom);
make.left.right.equalTo(self);
make.bottom.equalTo(self.inputView.mas_top);
}];
}
#pragma mark - Keyboard Observers
- (void)setupKeyboardObservers {
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(keyboardWillShow:)
name:UIKeyboardWillShowNotification
object:nil];
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(keyboardWillHide:)
name:UIKeyboardWillHideNotification
object:nil];
}
- (void)keyboardWillShow:(NSNotification *)notification {
CGRect keyboardFrame =
[notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
NSTimeInterval duration =
[notification.userInfo[UIKeyboardAnimationDurationUserInfoKey]
doubleValue];
self.keyboardHeight = keyboardFrame.size.height;
[self.inputBottomConstraint uninstall];
[self.inputView mas_updateConstraints:^(MASConstraintMaker *make) {
self.inputBottomConstraint =
make.bottom.equalTo(self).offset(-self.keyboardHeight);
}];
[UIView animateWithDuration:duration
animations:^{
[self layoutIfNeeded];
}];
}
- (void)keyboardWillHide:(NSNotification *)notification {
NSTimeInterval duration =
[notification.userInfo[UIKeyboardAnimationDurationUserInfoKey]
doubleValue];
self.keyboardHeight = 0;
[self.inputBottomConstraint uninstall];
[self.inputView mas_updateConstraints:^(MASConstraintMaker *make) {
self.inputBottomConstraint = make.bottom.equalTo(self);
}];
[UIView animateWithDuration:duration
animations:^{
[self layoutIfNeeded];
}];
}
#pragma mark - Data Loading
- (void)loadComments {
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"comments_mock"
ofType:@"json"];
if (!filePath) {
NSLog(@"[KBAICommentView] comments_mock.json not found");
return;
}
NSData *data = [NSData dataWithContentsOfFile:filePath];
if (!data) {
NSLog(@"[KBAICommentView] Failed to read comments_mock.json");
return;
}
NSError *error;
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data
options:0
error:&error];
if (error) {
NSLog(@"[KBAICommentView] JSON parse error: %@", error);
return;
}
self.totalCommentCount = [json[@"totalCount"] integerValue];
NSArray *commentsArray = json[@"comments"];
[self.comments removeAllObjects];
for (NSDictionary *dict in commentsArray) {
KBAICommentModel *comment = [KBAICommentModel mj_objectWithKeyValues:dict];
[self.comments addObject:comment];
}
[self updateTitle];
[self.tableView reloadData];
}
- (void)updateTitle {
NSString *countText;
if (self.totalCommentCount >= 10000) {
countText = [NSString
stringWithFormat:@"%.1fw条评论", self.totalCommentCount / 10000.0];
} else {
countText =
[NSString stringWithFormat:@"%ld条评论", (long)self.totalCommentCount];
}
self.titleLabel.text = countText;
}
#pragma mark - UITableViewDataSource
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return self.comments.count;
}
- (NSInteger)tableView:(UITableView *)tableView
numberOfRowsInSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
return comment.displayedReplies.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath {
KBAIReplyCell *cell =
[tableView dequeueReusableCellWithIdentifier:kReplyCellIdentifier
forIndexPath:indexPath];
KBAICommentModel *comment = self.comments[indexPath.section];
KBAIReplyModel *reply = comment.displayedReplies[indexPath.row];
[cell configureWithReply:reply];
__weak typeof(self) weakSelf = self;
cell.onLikeAction = ^{
// TODO: 处理点赞逻辑
reply.isLiked = !reply.isLiked;
reply.likeCount += reply.isLiked ? 1 : -1;
[weakSelf.tableView reloadRowsAtIndexPaths:@[ indexPath ]
withRowAnimation:UITableViewRowAnimationNone];
};
return cell;
}
#pragma mark - UITableViewDelegate
- (UIView *)tableView:(UITableView *)tableView
viewForHeaderInSection:(NSInteger)section {
KBAICommentHeaderView *header = [tableView
dequeueReusableHeaderFooterViewWithIdentifier:kCommentHeaderIdentifier];
KBAICommentModel *comment = self.comments[section];
[header configureWithComment:comment];
__weak typeof(self) weakSelf = self;
header.onLikeAction = ^{
// TODO: 处理点赞逻辑
comment.isLiked = !comment.isLiked;
comment.likeCount += comment.isLiked ? 1 : -1;
[weakSelf.tableView reloadSections:[NSIndexSet indexSetWithIndex:section]
withRowAnimation:UITableViewRowAnimationNone];
};
return header;
}
- (UIView *)tableView:(UITableView *)tableView
viewForFooterInSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
KBAIReplyFooterState state = [comment footerState];
// 无二级评论时返回空视图
if (state == KBAIReplyFooterStateHidden) {
NSLog(@"[KBAICommentView] footer hidden section=%ld", (long)section);
return nil;
}
KBAICommentFooterView *footer = [tableView
dequeueReusableHeaderFooterViewWithIdentifier:kCommentFooterIdentifier];
[footer configureWithComment:comment];
__weak typeof(self) weakSelf = self;
footer.onAction = ^{
[weakSelf handleFooterActionForSection:section];
};
return footer;
}
- (CGFloat)tableView:(UITableView *)tableView
heightForHeaderInSection:(NSInteger)section {
return UITableViewAutomaticDimension;
}
- (CGFloat)tableView:(UITableView *)tableView
heightForFooterInSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
KBAIReplyFooterState state = [comment footerState];
if (state == KBAIReplyFooterStateHidden) {
return CGFLOAT_MIN;
}
return 30;
}
- (CGFloat)tableView:(UITableView *)tableView
estimatedHeightForHeaderInSection:(NSInteger)section {
return 100;
}
- (CGFloat)tableView:(UITableView *)tableView
estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
return 60;
}
#pragma mark - Footer Actions
- (void)handleFooterActionForSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
KBAIReplyFooterState state = [comment footerState];
switch (state) {
case KBAIReplyFooterStateExpand:
case KBAIReplyFooterStateLoadMore: {
[self loadMoreRepliesForSection:section];
break;
}
case KBAIReplyFooterStateCollapse: {
[self collapseRepliesForSection:section];
break;
}
default:
break;
}
}
- (void)loadMoreRepliesForSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
NSInteger currentCount = comment.displayedReplies.count;
NSDictionary *anchor = [self captureHeaderAnchorForSection:section];
NSLog(@"[KBAICommentView] loadMore(before) section=%ld offsetY=%.2f contentSizeH=%.2f boundsH=%.2f rows=%ld",
(long)section,
self.tableView.contentOffset.y,
self.tableView.contentSize.height,
self.tableView.bounds.size.height,
(long)currentCount);
// 加载更多
[comment loadMoreReplies:kRepliesLoadCount];
// 计算新增的行
NSInteger newCount = comment.displayedReplies.count;
NSMutableArray *insertIndexPaths = [NSMutableArray array];
for (NSInteger i = currentCount; i < newCount; i++) {
[insertIndexPaths addObject:[NSIndexPath indexPathForRow:i
inSection:section]];
}
// 更新(避免动画导致跳动)
[UIView performWithoutAnimation:^{
[self.tableView beginUpdates];
if (insertIndexPaths.count > 0) {
[self.tableView insertRowsAtIndexPaths:insertIndexPaths
withRowAnimation:UITableViewRowAnimationNone];
}
[self.tableView endUpdates];
[self.tableView layoutIfNeeded];
[self restoreHeaderAnchor:anchor];
}];
NSLog(@"[KBAICommentView] loadMore(after) section=%ld offsetY=%.2f contentSizeH=%.2f boundsH=%.2f rows=%ld",
(long)section,
self.tableView.contentOffset.y,
self.tableView.contentSize.height,
self.tableView.bounds.size.height,
(long)comment.displayedReplies.count);
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"[KBAICommentView] loadMore(next) section=%ld offsetY=%.2f contentSizeH=%.2f boundsH=%.2f viewBoundsH=%.2f",
(long)section,
self.tableView.contentOffset.y,
self.tableView.contentSize.height,
self.tableView.bounds.size.height,
self.bounds.size.height);
});
// 手动刷新 Footer避免使用 reloadSections 导致滚动)
KBAICommentFooterView *footerView =
(KBAICommentFooterView *)[self.tableView footerViewForSection:section];
if (footerView) {
[footerView configureWithComment:comment];
}
}
- (void)collapseRepliesForSection:(NSInteger)section {
KBAICommentModel *comment = self.comments[section];
NSInteger rowCount = comment.displayedReplies.count;
NSDictionary *anchor = [self captureHeaderAnchorForSection:section];
NSLog(@"[KBAICommentView] collapse(before) section=%ld offsetY=%.2f contentSizeH=%.2f boundsH=%.2f rows=%ld",
(long)section,
self.tableView.contentOffset.y,
self.tableView.contentSize.height,
self.tableView.bounds.size.height,
(long)rowCount);
// 计算要删除的行
NSMutableArray *deleteIndexPaths = [NSMutableArray array];
for (NSInteger i = 0; i < rowCount; i++) {
[deleteIndexPaths addObject:[NSIndexPath indexPathForRow:i
inSection:section]];
}
// 收起回复
[comment collapseReplies];
// 删除行(避免动画导致跳动)
[UIView performWithoutAnimation:^{
[self.tableView beginUpdates];
if (deleteIndexPaths.count > 0) {
[self.tableView deleteRowsAtIndexPaths:deleteIndexPaths
withRowAnimation:UITableViewRowAnimationNone];
}
[self.tableView endUpdates];
[self.tableView layoutIfNeeded];
[self restoreHeaderAnchor:anchor];
}];
NSLog(@"[KBAICommentView] collapse(after) section=%ld offsetY=%.2f contentSizeH=%.2f boundsH=%.2f rows=%ld",
(long)section,
self.tableView.contentOffset.y,
self.tableView.contentSize.height,
self.tableView.bounds.size.height,
(long)comment.displayedReplies.count);
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"[KBAICommentView] collapse(next) section=%ld offsetY=%.2f contentSizeH=%.2f boundsH=%.2f viewBoundsH=%.2f",
(long)section,
self.tableView.contentOffset.y,
self.tableView.contentSize.height,
self.tableView.bounds.size.height,
self.bounds.size.height);
});
// 手动刷新 Footer避免使用 reloadSections 导致滚动)
KBAICommentFooterView *footerView =
(KBAICommentFooterView *)[self.tableView footerViewForSection:section];
if (footerView) {
[footerView configureWithComment:comment];
}
}
#pragma mark - Actions
- (void)closeButtonTapped {
// 关闭评论视图(由外部处理)
[[NSNotificationCenter defaultCenter]
postNotificationName:@"KBAICommentViewCloseNotification"
object:nil];
}
#pragma mark - Lazy Loading
- (UIView *)headerView {
if (!_headerView) {
_headerView = [[UIView alloc] init];
_headerView.backgroundColor = [UIColor whiteColor];
}
return _headerView;
}
- (UILabel *)titleLabel {
if (!_titleLabel) {
_titleLabel = [[UILabel alloc] init];
_titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightMedium];
_titleLabel.textColor = [UIColor labelColor];
_titleLabel.text = @"0条评论";
}
return _titleLabel;
}
- (UIButton *)closeButton {
if (!_closeButton) {
_closeButton = [UIButton buttonWithType:UIButtonTypeCustom];
[_closeButton setImage:[UIImage systemImageNamed:@"xmark"]
forState:UIControlStateNormal];
_closeButton.tintColor = [UIColor labelColor];
[_closeButton addTarget:self
action:@selector(closeButtonTapped)
forControlEvents:UIControlEventTouchUpInside];
}
return _closeButton;
}
- (UITableView *)tableView {
if (!_tableView) {
_tableView = [[UITableView alloc] initWithFrame:CGRectZero
style:UITableViewStyleGrouped];
_tableView.dataSource = self;
_tableView.delegate = self;
_tableView.backgroundColor = [UIColor whiteColor];
_tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
_tableView.estimatedRowHeight = 0;
_tableView.rowHeight = UITableViewAutomaticDimension;
_tableView.estimatedSectionHeaderHeight = 0;
_tableView.estimatedSectionFooterHeight = 0;
_tableView.sectionHeaderHeight = UITableViewAutomaticDimension;
_tableView.sectionFooterHeight = UITableViewAutomaticDimension;
_tableView.keyboardDismissMode = UIScrollViewKeyboardDismissModeOnDrag;
// 注册 Header/Cell/Footer
[_tableView registerClass:[KBAICommentHeaderView class]
forHeaderFooterViewReuseIdentifier:kCommentHeaderIdentifier];
[_tableView registerClass:[KBAIReplyCell class]
forCellReuseIdentifier:kReplyCellIdentifier];
[_tableView registerClass:[KBAICommentFooterView class]
forHeaderFooterViewReuseIdentifier:kCommentFooterIdentifier];
// 去掉顶部间距
if (@available(iOS 15.0, *)) {
_tableView.sectionHeaderTopPadding = 0;
}
}
return _tableView;
}
- (KBAICommentInputView *)inputView {
if (!_inputView) {
_inputView = [[KBAICommentInputView alloc] init];
_inputView.placeholder = @"说点什么...";
__weak typeof(self) weakSelf = self;
_inputView.onSend = ^(NSString *text) {
// TODO: 发送评论
NSLog(@"[KBAICommentView] Send comment: %@", text);
[weakSelf.inputView clearText];
};
}
return _inputView;
}
#pragma mark - Header Anchor
- (NSDictionary *)captureHeaderAnchorForSection:(NSInteger)section {
CGRect rect = [self.tableView rectForHeaderInSection:section];
NSLog(@"[KBAICommentView] anchor capture(header) section=%ld rect=%@ offsetY=%.2f contentSizeH=%.2f boundsH=%.2f",
(long)section,
NSStringFromCGRect(rect),
self.tableView.contentOffset.y,
self.tableView.contentSize.height,
self.tableView.bounds.size.height);
if (CGRectIsEmpty(rect) || CGRectIsNull(rect)) {
NSLog(@"[KBAICommentView] anchor capture(header) empty section=%ld",
(long)section);
return nil;
}
CGFloat offset = self.tableView.contentOffset.y - rect.origin.y;
return @{
@"section" : @(section),
@"offset" : @(offset),
@"fallbackOffset" : @(self.tableView.contentOffset.y)
};
}
- (void)restoreHeaderAnchor:(NSDictionary *)anchor {
if (!anchor) {
NSLog(@"[KBAICommentView] anchor restore(header) skipped (nil)");
return;
}
NSInteger section = [anchor[@"section"] integerValue];
if (section < 0 || section >= self.comments.count) {
NSLog(@"[KBAICommentView] anchor restore(header) invalid section=%ld",
(long)section);
return;
}
CGRect rect = [self.tableView rectForHeaderInSection:section];
NSLog(@"[KBAICommentView] anchor restore(header) section=%ld rect=%@",
(long)section,
NSStringFromCGRect(rect));
if (CGRectIsEmpty(rect) || CGRectIsNull(rect)) {
NSNumber *fallbackOffset = anchor[@"fallbackOffset"];
if (!fallbackOffset) {
NSLog(@"[KBAICommentView] anchor restore(header) no fallback section=%ld",
(long)section);
return;
}
NSLog(@"[KBAICommentView] anchor restore(header) fallback section=%ld offsetY=%.2f",
(long)section,
[fallbackOffset doubleValue]);
[self setTableViewOffset:[fallbackOffset doubleValue]];
return;
}
CGFloat targetOffsetY = rect.origin.y + [anchor[@"offset"] doubleValue];
NSLog(@"[KBAICommentView] anchor restore(header) target section=%ld targetY=%.2f",
(long)section,
targetOffsetY);
[self setTableViewOffset:targetOffsetY];
}
- (void)setTableViewOffset:(CGFloat)offsetY {
UIEdgeInsets inset = self.tableView.adjustedContentInset;
CGFloat minOffsetY = -inset.top;
CGFloat maxOffsetY =
MAX(minOffsetY, self.tableView.contentSize.height + inset.bottom -
self.tableView.bounds.size.height);
CGFloat targetOffsetY = MIN(MAX(offsetY, minOffsetY), maxOffsetY);
NSLog(@"[KBAICommentView] set offset target=%.2f min=%.2f max=%.2f",
targetOffsetY,
minOffsetY,
maxOffsetY);
[self.tableView setContentOffset:CGPointMake(0, targetOffsetY)
animated:NO];
}
@end