添加评论

This commit is contained in:
2026-01-16 15:55:08 +08:00
parent b021fd308f
commit 28852a8d4b
18 changed files with 1977 additions and 26 deletions

View File

@@ -0,0 +1,25 @@
//
// KBAICommentFooterView.h
// keyBoard
//
// Created by Mac on 2026/1/16.
//
#import <UIKit/UIKit.h>
@class KBAICommentModel;
NS_ASSUME_NONNULL_BEGIN
/// Section Footer - 展开/收起/加载更多按钮
@interface KBAICommentFooterView : UITableViewHeaderFooterView
/// 配置 Footer 数据
- (void)configureWithComment:(KBAICommentModel *)comment;
/// 操作按钮点击回调
@property(nonatomic, copy, nullable) void (^onAction)(void);
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,131 @@
//
// KBAICommentFooterView.m
// keyBoard
//
// Created by Mac on 2026/1/16.
//
#import "KBAICommentFooterView.h"
#import "KBAICommentModel.h"
#import <Masonry/Masonry.h>
@interface KBAICommentFooterView ()
@property(nonatomic, strong) UIButton *actionButton;
@property(nonatomic, strong) UIView *lineView;
@end
@implementation KBAICommentFooterView
- (instancetype)initWithReuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithReuseIdentifier:reuseIdentifier];
if (self) {
[self setupUI];
}
return self;
}
#pragma mark - UI Setup
- (void)setupUI {
self.contentView.backgroundColor = [UIColor whiteColor];
[self.contentView addSubview:self.actionButton];
[self.contentView addSubview:self.lineView];
[self.actionButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.contentView).offset(68); //
make.centerY.equalTo(self.contentView);
make.height.mas_equalTo(24);
}];
[self.lineView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.contentView).offset(16);
make.right.equalTo(self.contentView).offset(-16);
make.bottom.equalTo(self.contentView);
make.height.mas_equalTo(0.5);
}];
}
#pragma mark - Configuration
- (void)configureWithComment:(KBAICommentModel *)comment {
KBAIReplyFooterState state = [comment footerState];
NSString *title = @"";
switch (state) {
case KBAIReplyFooterStateHidden: {
self.actionButton.hidden = YES;
break;
}
case KBAIReplyFooterStateExpand: {
self.actionButton.hidden = NO;
title = [NSString
stringWithFormat:@"展开%ld条回复", (long)comment.totalReplyCount];
[self.actionButton setImage:[UIImage systemImageNamed:@"chevron.down"]
forState:UIControlStateNormal];
break;
}
case KBAIReplyFooterStateLoadMore: {
self.actionButton.hidden = NO;
NSInteger remaining =
comment.totalReplyCount - comment.displayedReplies.count;
title =
[NSString stringWithFormat:@"展开更多回复 (%ld条)", (long)remaining];
[self.actionButton setImage:[UIImage systemImageNamed:@"chevron.down"]
forState:UIControlStateNormal];
break;
}
case KBAIReplyFooterStateCollapse: {
self.actionButton.hidden = NO;
title = @"收起";
[self.actionButton setImage:[UIImage systemImageNamed:@"chevron.up"]
forState:UIControlStateNormal];
break;
}
}
[self.actionButton setTitle:title forState:UIControlStateNormal];
}
#pragma mark - Actions
- (void)actionButtonTapped {
if (self.onAction) {
self.onAction();
}
}
#pragma mark - Lazy Loading
- (UIButton *)actionButton {
if (!_actionButton) {
_actionButton = [UIButton buttonWithType:UIButtonTypeCustom];
_actionButton.titleLabel.font = [UIFont systemFontOfSize:13];
[_actionButton setTitleColor:[UIColor secondaryLabelColor]
forState:UIControlStateNormal];
_actionButton.tintColor = [UIColor secondaryLabelColor];
//
_actionButton.semanticContentAttribute =
UISemanticContentAttributeForceLeftToRight;
_actionButton.imageEdgeInsets = UIEdgeInsetsMake(0, 4, 0, -4);
_actionButton.titleEdgeInsets = UIEdgeInsetsMake(0, -4, 0, 4);
[_actionButton addTarget:self
action:@selector(actionButtonTapped)
forControlEvents:UIControlEventTouchUpInside];
}
return _actionButton;
}
- (UIView *)lineView {
if (!_lineView) {
_lineView = [[UIView alloc] init];
_lineView.backgroundColor = [UIColor separatorColor];
}
return _lineView;
}
@end

View File

@@ -0,0 +1,25 @@
//
// KBAICommentHeaderView.h
// keyBoard
//
// Created by Mac on 2026/1/16.
//
#import <UIKit/UIKit.h>
@class KBAICommentModel;
NS_ASSUME_NONNULL_BEGIN
/// 一级评论 Section Header 视图
@interface KBAICommentHeaderView : UITableViewHeaderFooterView
/// 配置评论数据
- (void)configureWithComment:(KBAICommentModel *)comment;
/// 点赞按钮点击回调
@property(nonatomic, copy, nullable) void (^onLikeAction)(void);
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,178 @@
//
// KBAICommentHeaderView.m
// keyBoard
//
// Created by Mac on 2026/1/16.
//
#import "KBAICommentHeaderView.h"
#import "KBAICommentModel.h"
#import <Masonry/Masonry.h>
#import <SDWebImage/SDWebImage.h>
@interface KBAICommentHeaderView ()
@property(nonatomic, strong) UIImageView *avatarImageView;
@property(nonatomic, strong) UILabel *userNameLabel;
@property(nonatomic, strong) UILabel *timeLabel;
@property(nonatomic, strong) UILabel *contentLabel;
@property(nonatomic, strong) UIButton *likeButton;
@end
@implementation KBAICommentHeaderView
- (instancetype)initWithReuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithReuseIdentifier:reuseIdentifier];
if (self) {
[self setupUI];
}
return self;
}
#pragma mark - UI Setup
- (void)setupUI {
self.contentView.backgroundColor = [UIColor whiteColor];
[self.contentView addSubview:self.avatarImageView];
[self.contentView addSubview:self.userNameLabel];
[self.contentView addSubview:self.timeLabel];
[self.contentView addSubview:self.contentLabel];
[self.contentView addSubview:self.likeButton];
[self.avatarImageView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.contentView).offset(16);
make.top.equalTo(self.contentView).offset(12);
make.width.height.mas_equalTo(40);
}];
[self.userNameLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.avatarImageView.mas_right).offset(12);
make.top.equalTo(self.avatarImageView);
make.right.lessThanOrEqualTo(self.likeButton.mas_left).offset(-10);
}];
[self.timeLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.userNameLabel);
make.top.equalTo(self.userNameLabel.mas_bottom).offset(2);
}];
[self.contentLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.userNameLabel);
make.top.equalTo(self.timeLabel.mas_bottom).offset(8);
make.right.equalTo(self.contentView).offset(-50);
make.bottom.equalTo(self.contentView).offset(-12);
}];
[self.likeButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.contentView).offset(-16);
make.top.equalTo(self.contentView).offset(12);
make.width.mas_equalTo(50);
make.height.mas_equalTo(40);
}];
}
#pragma mark - Configuration
- (void)configureWithComment:(KBAICommentModel *)comment {
[self.avatarImageView
sd_setImageWithURL:[NSURL URLWithString:comment.avatarUrl]
placeholderImage:[UIImage imageNamed:@"default_avatar"]];
self.userNameLabel.text = comment.userName;
self.timeLabel.text = [comment formattedTime];
self.contentLabel.text = comment.content;
//
NSString *likeText =
comment.likeCount > 0 ? [self formatLikeCount:comment.likeCount] : @"赞";
[self.likeButton setTitle:likeText forState:UIControlStateNormal];
UIImage *likeImage = comment.isLiked
? [UIImage systemImageNamed:@"heart.fill"]
: [UIImage systemImageNamed:@"heart"];
[self.likeButton setImage:likeImage forState:UIControlStateNormal];
self.likeButton.tintColor =
comment.isLiked ? [UIColor systemRedColor] : [UIColor grayColor];
}
- (NSString *)formatLikeCount:(NSInteger)count {
if (count >= 10000) {
return [NSString stringWithFormat:@"%.1fw", count / 10000.0];
} else if (count >= 1000) {
return [NSString stringWithFormat:@"%.1fk", count / 1000.0];
}
return [NSString stringWithFormat:@"%ld", (long)count];
}
#pragma mark - Actions
- (void)likeButtonTapped {
if (self.onLikeAction) {
self.onLikeAction();
}
}
#pragma mark - Lazy Loading
- (UIImageView *)avatarImageView {
if (!_avatarImageView) {
_avatarImageView = [[UIImageView alloc] init];
_avatarImageView.contentMode = UIViewContentModeScaleAspectFill;
_avatarImageView.layer.cornerRadius = 20;
_avatarImageView.layer.masksToBounds = YES;
_avatarImageView.backgroundColor = [UIColor systemGray5Color];
}
return _avatarImageView;
}
- (UILabel *)userNameLabel {
if (!_userNameLabel) {
_userNameLabel = [[UILabel alloc] init];
_userNameLabel.font = [UIFont systemFontOfSize:14
weight:UIFontWeightMedium];
_userNameLabel.textColor = [UIColor labelColor];
}
return _userNameLabel;
}
- (UILabel *)timeLabel {
if (!_timeLabel) {
_timeLabel = [[UILabel alloc] init];
_timeLabel.font = [UIFont systemFontOfSize:12];
_timeLabel.textColor = [UIColor secondaryLabelColor];
}
return _timeLabel;
}
- (UILabel *)contentLabel {
if (!_contentLabel) {
_contentLabel = [[UILabel alloc] init];
_contentLabel.font = [UIFont systemFontOfSize:15];
_contentLabel.textColor = [UIColor labelColor];
_contentLabel.numberOfLines = 0;
}
return _contentLabel;
}
- (UIButton *)likeButton {
if (!_likeButton) {
_likeButton = [UIButton buttonWithType:UIButtonTypeCustom];
_likeButton.titleLabel.font = [UIFont systemFontOfSize:12];
[_likeButton setTitleColor:[UIColor grayColor]
forState:UIControlStateNormal];
[_likeButton setImage:[UIImage systemImageNamed:@"heart"]
forState:UIControlStateNormal];
_likeButton.tintColor = [UIColor grayColor];
//
_likeButton.contentHorizontalAlignment =
UIControlContentHorizontalAlignmentCenter;
[_likeButton addTarget:self
action:@selector(likeButtonTapped)
forControlEvents:UIControlEventTouchUpInside];
}
return _likeButton;
}
@end

View File

@@ -0,0 +1,26 @@
//
// KBAICommentInputView.h
// keyBoard
//
// Created by Mac on 2026/1/16.
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
/// 底部评论输入框
@interface KBAICommentInputView : UIView
/// 发送回调,参数为输入的文本
@property(nonatomic, copy, nullable) void (^onSend)(NSString *text);
/// 设置占位文字
@property(nonatomic, copy) NSString *placeholder;
/// 清空输入框
- (void)clearText;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,176 @@
//
// KBAICommentInputView.m
// keyBoard
//
// Created by Mac on 2026/1/16.
//
#import "KBAICommentInputView.h"
#import <Masonry/Masonry.h>
@interface KBAICommentInputView () <UITextFieldDelegate>
@property(nonatomic, strong) UIView *containerView;
@property(nonatomic, strong) UIImageView *avatarImageView;
@property(nonatomic, strong) UITextField *textField;
@property(nonatomic, strong) UIButton *sendButton;
@property(nonatomic, strong) UIView *topLine;
@end
@implementation KBAICommentInputView
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self setupUI];
}
return self;
}
#pragma mark - UI Setup
- (void)setupUI {
self.backgroundColor = [UIColor whiteColor];
[self addSubview:self.topLine];
[self addSubview:self.avatarImageView];
[self addSubview:self.containerView];
[self.containerView addSubview:self.textField];
[self addSubview:self.sendButton];
[self.topLine mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.top.equalTo(self);
make.height.mas_equalTo(0.5);
}];
[self.avatarImageView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self).offset(12);
make.centerY.equalTo(self);
make.width.height.mas_equalTo(32);
}];
[self.containerView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.avatarImageView.mas_right).offset(10);
make.right.equalTo(self.sendButton.mas_left).offset(-10);
make.centerY.equalTo(self);
make.height.mas_equalTo(36);
}];
[self.textField mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.containerView).offset(12);
make.right.equalTo(self.containerView).offset(-12);
make.centerY.equalTo(self.containerView);
}];
[self.sendButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self).offset(-12);
make.centerY.equalTo(self);
make.width.mas_equalTo(50);
make.height.mas_equalTo(30);
}];
}
#pragma mark - Public Methods
- (void)setPlaceholder:(NSString *)placeholder {
_placeholder = placeholder;
self.textField.placeholder = placeholder;
}
- (void)clearText {
self.textField.text = @"";
[self updateSendButtonState];
}
#pragma mark - Actions
- (void)sendButtonTapped {
NSString *text = self.textField.text;
if (text.length > 0 && self.onSend) {
self.onSend(text);
}
}
- (void)textFieldDidChange:(UITextField *)textField {
[self updateSendButtonState];
}
- (void)updateSendButtonState {
BOOL hasText = self.textField.text.length > 0;
self.sendButton.enabled = hasText;
self.sendButton.alpha = hasText ? 1.0 : 0.5;
}
#pragma mark - UITextFieldDelegate
- (BOOL)textFieldShouldReturn:(UITextField *)textField {
[self sendButtonTapped];
return YES;
}
#pragma mark - Lazy Loading
- (UIView *)topLine {
if (!_topLine) {
_topLine = [[UIView alloc] init];
_topLine.backgroundColor = [UIColor separatorColor];
}
return _topLine;
}
- (UIImageView *)avatarImageView {
if (!_avatarImageView) {
_avatarImageView = [[UIImageView alloc] init];
_avatarImageView.contentMode = UIViewContentModeScaleAspectFill;
_avatarImageView.layer.cornerRadius = 16;
_avatarImageView.layer.masksToBounds = YES;
_avatarImageView.backgroundColor = [UIColor systemGray5Color];
_avatarImageView.image = [UIImage systemImageNamed:@"person.circle.fill"];
_avatarImageView.tintColor = [UIColor systemGray3Color];
}
return _avatarImageView;
}
- (UIView *)containerView {
if (!_containerView) {
_containerView = [[UIView alloc] init];
_containerView.backgroundColor = [UIColor systemGray6Color];
_containerView.layer.cornerRadius = 18;
_containerView.layer.masksToBounds = YES;
}
return _containerView;
}
- (UITextField *)textField {
if (!_textField) {
_textField = [[UITextField alloc] init];
_textField.placeholder = @"说点什么...";
_textField.font = [UIFont systemFontOfSize:14];
_textField.delegate = self;
_textField.returnKeyType = UIReturnKeySend;
[_textField addTarget:self
action:@selector(textFieldDidChange:)
forControlEvents:UIControlEventEditingChanged];
}
return _textField;
}
- (UIButton *)sendButton {
if (!_sendButton) {
_sendButton = [UIButton buttonWithType:UIButtonTypeCustom];
[_sendButton setTitle:@"发送" forState:UIControlStateNormal];
[_sendButton setTitleColor:[UIColor systemBlueColor]
forState:UIControlStateNormal];
_sendButton.titleLabel.font = [UIFont systemFontOfSize:15
weight:UIFontWeightMedium];
_sendButton.enabled = NO;
_sendButton.alpha = 0.5;
[_sendButton addTarget:self
action:@selector(sendButtonTapped)
forControlEvents:UIControlEventTouchUpInside];
}
return _sendButton;
}
@end

View File

@@ -9,8 +9,15 @@
NS_ASSUME_NONNULL_BEGIN
/// 抖音风格评论视图
@interface KBAICommentView : UIView
/// 加载评论数据(从本地 JSON 文件)
- (void)loadComments;
/// 评论总数
@property(nonatomic, readonly) NSInteger totalCommentCount;
@end
NS_ASSUME_NONNULL_END

View File

@@ -6,15 +6,610 @@
//
#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
/*
// Only override drawRect: if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
- (void)drawRect:(CGRect)rect {
// Drawing code
#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

View File

@@ -0,0 +1,25 @@
//
// KBAIReplyCell.h
// keyBoard
//
// Created by Mac on 2026/1/16.
//
#import <UIKit/UIKit.h>
@class KBAIReplyModel;
NS_ASSUME_NONNULL_BEGIN
/// 二级评论 Cell
@interface KBAIReplyCell : UITableViewCell
/// 配置回复数据
- (void)configureWithReply:(KBAIReplyModel *)reply;
/// 点赞按钮点击回调
@property(nonatomic, copy, nullable) void (^onLikeAction)(void);
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,198 @@
//
// KBAIReplyCell.m
// keyBoard
//
// Created by Mac on 2026/1/16.
//
#import "KBAIReplyCell.h"
#import "KBAIReplyModel.h"
#import <Masonry/Masonry.h>
#import <SDWebImage/SDWebImage.h>
@interface KBAIReplyCell ()
@property(nonatomic, strong) UIImageView *avatarImageView;
@property(nonatomic, strong) UILabel *contentLabel;
@property(nonatomic, strong) UILabel *timeLabel;
@property(nonatomic, strong) UIButton *likeButton;
@end
@implementation KBAIReplyCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style
reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self) {
self.selectionStyle = UITableViewCellSelectionStyleNone;
self.backgroundColor = [UIColor whiteColor];
[self setupUI];
}
return self;
}
#pragma mark - UI Setup
- (void)setupUI {
[self.contentView addSubview:self.avatarImageView];
[self.contentView addSubview:self.contentLabel];
[self.contentView addSubview:self.timeLabel];
[self.contentView addSubview:self.likeButton];
// 48pt
[self.avatarImageView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.contentView).offset(68); // 16 + 40 + 12 = 68
make.top.equalTo(self.contentView).offset(8);
make.width.height.mas_equalTo(28);
}];
[self.contentLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.avatarImageView.mas_right).offset(8);
make.top.equalTo(self.avatarImageView);
make.right.equalTo(self.likeButton.mas_left).offset(-8);
}];
[self.timeLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.contentLabel);
make.top.equalTo(self.contentLabel.mas_bottom).offset(4);
make.bottom.equalTo(self.contentView).offset(-8);
}];
[self.likeButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.contentView).offset(-16);
make.top.equalTo(self.contentView).offset(8);
make.width.mas_equalTo(40);
make.height.mas_equalTo(30);
}];
}
#pragma mark - Configuration
- (void)configureWithReply:(KBAIReplyModel *)reply {
[self.avatarImageView
sd_setImageWithURL:[NSURL URLWithString:reply.avatarUrl]
placeholderImage:[UIImage imageNamed:@"default_avatar"]];
// + @+
NSMutableAttributedString *attrText =
[[NSMutableAttributedString alloc] init];
//
NSDictionary *nameAttrs = @{
NSFontAttributeName : [UIFont systemFontOfSize:14
weight:UIFontWeightMedium],
NSForegroundColorAttributeName : [UIColor labelColor]
};
[attrText appendAttributedString:[[NSAttributedString alloc]
initWithString:reply.userName
attributes:nameAttrs]];
// @
if (reply.replyToUserName.length > 0) {
NSDictionary *replyAttrs = @{
NSFontAttributeName : [UIFont systemFontOfSize:14],
NSForegroundColorAttributeName : [UIColor systemBlueColor]
};
NSString *replyTo =
[NSString stringWithFormat:@" 回复 @%@", reply.replyToUserName];
[attrText appendAttributedString:[[NSAttributedString alloc]
initWithString:replyTo
attributes:replyAttrs]];
}
//
NSDictionary *contentAttrs = @{
NSFontAttributeName : [UIFont systemFontOfSize:14],
NSForegroundColorAttributeName : [UIColor labelColor]
};
NSString *content = [NSString stringWithFormat:@"%@", reply.content];
[attrText appendAttributedString:[[NSAttributedString alloc]
initWithString:content
attributes:contentAttrs]];
self.contentLabel.attributedText = attrText;
//
self.timeLabel.text = [reply formattedTime];
//
NSString *likeText =
reply.likeCount > 0 ? [self formatLikeCount:reply.likeCount] : @"";
[self.likeButton setTitle:likeText forState:UIControlStateNormal];
UIImage *likeImage = reply.isLiked ? [UIImage systemImageNamed:@"heart.fill"]
: [UIImage systemImageNamed:@"heart"];
[self.likeButton setImage:likeImage forState:UIControlStateNormal];
self.likeButton.tintColor =
reply.isLiked ? [UIColor systemRedColor] : [UIColor grayColor];
}
- (NSString *)formatLikeCount:(NSInteger)count {
if (count >= 10000) {
return [NSString stringWithFormat:@"%.1fw", count / 10000.0];
} else if (count >= 1000) {
return [NSString stringWithFormat:@"%.1fk", count / 1000.0];
}
return [NSString stringWithFormat:@"%ld", (long)count];
}
#pragma mark - Actions
- (void)likeButtonTapped {
if (self.onLikeAction) {
self.onLikeAction();
}
}
#pragma mark - Lazy Loading
- (UIImageView *)avatarImageView {
if (!_avatarImageView) {
_avatarImageView = [[UIImageView alloc] init];
_avatarImageView.contentMode = UIViewContentModeScaleAspectFill;
_avatarImageView.layer.cornerRadius = 14;
_avatarImageView.layer.masksToBounds = YES;
_avatarImageView.backgroundColor = [UIColor systemGray5Color];
}
return _avatarImageView;
}
- (UILabel *)contentLabel {
if (!_contentLabel) {
_contentLabel = [[UILabel alloc] init];
_contentLabel.numberOfLines = 0;
}
return _contentLabel;
}
- (UILabel *)timeLabel {
if (!_timeLabel) {
_timeLabel = [[UILabel alloc] init];
_timeLabel.font = [UIFont systemFontOfSize:11];
_timeLabel.textColor = [UIColor secondaryLabelColor];
}
return _timeLabel;
}
- (UIButton *)likeButton {
if (!_likeButton) {
_likeButton = [UIButton buttonWithType:UIButtonTypeCustom];
_likeButton.titleLabel.font = [UIFont systemFontOfSize:11];
[_likeButton setTitleColor:[UIColor grayColor]
forState:UIControlStateNormal];
[_likeButton setImage:[UIImage systemImageNamed:@"heart"]
forState:UIControlStateNormal];
_likeButton.tintColor = [UIColor grayColor];
[_likeButton addTarget:self
action:@selector(likeButtonTapped)
forControlEvents:UIControlEventTouchUpInside];
//
_likeButton.imageEdgeInsets = UIEdgeInsetsMake(0, -2, 0, 2);
_likeButton.titleEdgeInsets = UIEdgeInsetsMake(0, 2, 0, -2);
}
return _likeButton;
}
@end