// // 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 #import static NSString *const kCommentHeaderIdentifier = @"CommentHeader"; static NSString *const kReplyCellIdentifier = @"ReplyCell"; static NSString *const kCommentFooterIdentifier = @"CommentFooter"; /// 每次展开的回复数量 static NSInteger const kRepliesLoadCount = 3; @interface KBAICommentView () @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 *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