GYTableViewController
程序员文章站
2022-04-19 17:21:59
自定义封装UITableView,更加简洁高效,无需为了实现delegate增加胶水代码,自带下拉刷新上拉加载控件 "项目仓库地址" 如何开始 "项目技术特点" "安装方法" "框架用法" 代码结构 "GYTableBaseView.h" "GYTableViewController.h" "GYT ......
自定义封装UITableView,更加简洁高效,无需为了实现delegate增加胶水代码,自带下拉刷新上拉加载控件
- 如何开始
- 代码结构
- 使用示例
技术特点
- Section和Cell层次更加清晰,根据传入的Section数据结构内部已经全部实现Section和Cell相关delegate方法
- Cell实例可获得外部动态数据,索引位置,上下关系,选中状态等,随时更换样式
- Controller自带MJRefresh框架,提供下拉刷新和上拉加载功能,外部暴露接口调用
- 提供Cell,Section间距设置,提供选中行高亮、选中行自动居中,提供设置Cell动态高度设置等API
- 框架中的元素全部继承于原生的tableView相关元素,除部分代理方法外,其他原生方法扔然可以使用
安装方法
- pod安装: pod 'GYTableViewController'
- 手动安装:手动安装需要添加两个库,将GYTableViewController项目文件中Framework文件下的文件导入自身项目,同时此框架基于MJRefresh,所以也需要导入MJRefresh框架文件,手动或者pod都可以,MJRefresh安装方法
框架用法
请使用该框架中的元素来代替原生列表控件,对应关系如下:
GYTableBaseView -> UITableView GYTableViewController -> UITableViewController GYTableViewCell -> UITableViewCell GYTableViewSection 原生使用UIView展示section内容,这里使用GYTableViewSection SectionVo 用来设置Section样式与GYTableViewSection实例绑定 CellVo 用来设置Cell样式与GYTableViewCell实例绑定
使用时有列表控件的界面直接继承GYTableViewController,.h示例如下
#import "GYTableViewController.h" @interface NormalTableViewController : GYTableViewController
.m文件中重写headerRefresh添加元素,当自带的下拉刷新控件下拉时调用;从而开始列表内容层次搭建,以及各种类型的Cell位置如何摆放等
-(void)headerRefresh:(GYTableBaseView *)tableView endRefreshHandler:(HeaderRefreshHandler)endRefreshHandler{ //下拉刷新后开始请求后台提供数据,请求到数据后根据解析的内容展开cell实例和位置等操作,代码结构如下(伪代码) request{ tableView{ sectionVo{ cellVo, cellVo, ... } sectionVo{ cellVo, ... } ... } endRefreshHandler();//界面搭建完毕后停止刷新 } }
Cell控件直接继承GYTableViewCell,.h示例如下
#import "GYTableViewCell.h" @interface NormalViewCell : GYTableViewCell
.m文件中重写showSubviews方法进行布局,利用GET_CELL_DATA或GET_CELL_ARRAY_DATA宏来获取列表控件中传入的数据,如传入非数组型的数据使用GET_CELL_DATA,反之使用GET_CELL_ARRAY_DATA
-(void)showSubviews{ GET_CELL_DATA([xxx class]);//先获取外部传入的数据 GET_CELL_ARRAY_DATA([xxx class]);//或者获取数组类型的数据 //开始界面布局... }
GYTableBaseView.h
@interface GYTableBaseView : UITableView /** 设置选中位置 **/ @property(nonatomic,retain) NSIndexPath* selectedIndexPath; /** 点中cell高亮 **/ @property (nonatomic,assign) BOOL clickCellHighlight; /** 点中cell自动居中 **/ @property (nonatomic,assign) BOOL clickCellMoveToCenter; /** 每一节间距 **/ @property (nonatomic,assign) CGFloat sectionGap; /** 每个cell间距 **/ @property (nonatomic,assign) CGFloat cellGap; /** 首次下拉 **/ @property (nonatomic,assign,readonly) BOOL hasFirstRefreshed; /** 下拉刷新 **/ -(void)headerBeginRefresh; /** 检查数据间距 **/ -(void)checkGaps; /** 清除所有数据 **/ -(void)clearAllSectionVo; /** 添加一节内容 **/ -(void)addSectionVo:(SectionVo*)sectionVo; /** 在某个索引插入一节内容 **/ -(void)insertSectionVo:(SectionVo*)sectionVo atIndex:(NSInteger)index; /** 删除一节内容 **/ -(void)removeSectionVoAt:(NSInteger)index; /** 重新刷新全部界面 类似源生的reloadData **/ -(void)reloadGYData; /** 将选中的数据项平滑居中移动 **/ -(void)moveSelectedIndexPathToCenter; /** 获取最后一个SectionVo **/ -(SectionVo*)getLastSectionVo; /** 获取第一个SectionVo **/ -(SectionVo*)getFirstSectionVo; /** 获取目标索引位置SectionVo **/ -(SectionVo*)getSectionVoByIndex:(NSInteger)index; /** 获取目标indexPath位置CellVo **/ -(CellVo*)getCellVoByIndexPath:(NSIndexPath*)indexPath; /** 获取SectionVo总数 **/ -(NSUInteger)getSectionVoCount; /** 获取CellVo总数 所有SectionVo包含的CellVo总和 **/ -(NSUInteger)getTotalCellVoCount; @end
SectionVo
@interface SectionVo : NSObject /** 创建SectionVo实例并初始化设置下一步回调 **/ + (instancetype)initWithParams:(void(^)(SectionVo* svo))nextBlock; /** 创建SectionVo实例并初始化设置section页眉高度、section页眉类型、section页眉数据、下一步回调 **/ + (instancetype)initWithParams:(CGFloat)sectionHeaderHeight sectionHeaderClass:(Class)sectionHeaderClass sectionHeaderData:(id)sectionHeaderData nextBlock:(void(^)(SectionVo* svo))nextBlock; /** 创建SectionVo实例并初始化设置section页眉高度、section页眉类型、section页眉数据、section页脚高度、section页脚类型、section页脚数据、下一步回调 **/ + (instancetype)initWithParams:(CGFloat)sectionHeaderHeight sectionHeaderClass:(Class)sectionHeaderClass sectionHeaderData:(id)sectionHeaderData sectionFooterHeight:(CGFloat)sectionFooterHeight sectionFooterClass:(Class)sectionFooterClass sectionFooterData:(id)sectionFooterData nextBlock:(void(^)(SectionVo* svo))nextBlock; /** GYTableViewSection页眉实例高度 **/ @property (nonatomic,assign)CGFloat sectionHeaderHeight; /** 用来实例化GYTableViewSection页眉的自定义类型 **/ @property (nonatomic,retain)Class sectionHeaderClass; /** 传递给GYTableViewSection页眉实例的数据,用来展示界面判断逻辑等,实例内部通过self.data属性获得 **/ @property (nonatomic,retain)id sectionHeaderData; /** GYTableViewSection页脚实例高度 **/ @property (nonatomic,assign)CGFloat sectionFooterHeight; /** 用来实例化GYTableViewSection页脚的自定义类型 **/ @property (nonatomic,retain)Class sectionFooterClass; /** 传递给GYTableViewSection页脚实例的数据,用来展示界面判断逻辑等,实例内部通过self.data属性获得 **/ @property (nonatomic,retain)id sectionFooterData; /** 该节包含的CellVo个数 **/ -(NSInteger)getCellVoCount; /** 添加单个CellVo **/ -(void)addCellVo:(CellVo*)cellVo; /** 批量添加CellVo **/ -(void)addCellVoByList:(NSArray<CellVo*>*)otherVoList; @end
CellVo
@interface CellVo : NSObject /** 创建CellVo实例并初始化设置cell高度、cell类型、cell数据 **/ + (instancetype)initWithParams:(CGFloat)cellHeight cellClass:(Class)cellClass cellData:(id)cellData; /** 创建CellVo实例并初始化设置cell高度、cell类型、cell数据、cell是否唯一 **/ + (instancetype)initWithParams:(CGFloat)cellHeight cellClass:(Class)cellClass cellData:(id)cellData isUnique:(BOOL)isUnique; /** 创建CellVo实例并初始化设置cell高度、cell类型、cell数据、cell是否唯一、是否强制刷新 **/ + (instancetype)initWithParams:(CGFloat)cellHeight cellClass:(Class)cellClass cellData:(id)cellData isUnique:(BOOL)isUnique forceUpdate:(BOOL)forceUpdate; /** 通过原始数据数组批量创建CellVo并将数据分别对应存入 **/ +(NSArray<CellVo*>*)dividingCellVoBySourceArray:(CGFloat)cellHeight cellClass:(Class)cellClass sourceArray:(NSArray*)sourceArray; /** GYTableViewCell实例高度 **/ @property (nonatomic,assign)CGFloat cellHeight; /** 用来实例化GYTableViewCell的自定义类型 **/ @property (nonatomic,retain)Class cellClass; /** 传递给GYTableViewCell实例的数据,用来展示界面判断逻辑等,实例内部通过self.data属性获得 **/ @property (nonatomic,retain)id cellData; /** 创建GYTableViewCell实例唯一,相当于单例,就算是相同类型的GYTableViewCell也是各自成为单例,且内容不会刷新(适合内容只初始化一次的界面) **/ @property (nonatomic,assign)BOOL isUnique; /** isUnique设置为true的情况下 forceUpdate可以继续让单例GYTableViewCell中的内容强制刷新 **/ @property (nonatomic,assign)BOOL forceUpdate; /** 自定义保留字段 **/ @property (nonatomic,copy)NSString* cellName; @end
GYTableViewController.h
@interface GYTableViewController : UIViewController<GYTableBaseViewDelegate> /** controller包含的tableView实例 **/ @property(nonatomic,retain)GYTableBaseView* tableView; /** 设置进入该页面是否自动下拉刷新 默认true **/ @property(nonatomic,assign)BOOL autoRefreshHeader; /** 设置选中某个indexPath位置 **/ @property(nonatomic,retain)NSIndexPath* selectedIndexPath; /** 设置是否标记重用cell实例 默认true **/ @property(nonatomic,assign)BOOL useCellIdentifer; /** 设置每次进入该页面自动滚动到列表顶部 默认false **/ @property(nonatomic,assign)BOOL autoRestOffset; /** 设置是否显示下拉刷新控件 默认true **/ @property(nonatomic,assign)BOOL isShowHeader; /** 设置是否显示上拉加载控件 默认false **/ @property(nonatomic,assign)BOOL isShowFooter; /** 设置TableView的布局位置,默认铺满Controller **/ -(CGRect)getTableViewFrame; /** 设置自定义下拉刷新控件实例 **/ -(MJRefreshHeader*)getRefreshHeader; /** 设置自定义上拉加载控件实例 **/ -(MJRefreshFooter*)getRefreshFooter; @end
GYTableBaseViewDelegate
/** 下拉刷新或上拉加载调用结束Block hasData标注界面是否刷新出了新的数据 **/ typedef void(^HeaderRefreshHandler)(BOOL hasData); typedef void(^FooterLoadMoreHandler)(BOOL hasData); @protocol GYTableBaseViewDelegate<UITableViewDelegate,UITableViewDataSource> @optional /** 当GYTableBaseView下拉刷新时代理调用 **/ -(void)headerRefresh:(GYTableBaseView*)tableView endRefreshHandler:(HeaderRefreshHandler)endRefreshHandler; @optional /** 当GYTableBaseView上拉加载时代理调用 **/ -(void)footerLoadMore:(GYTableBaseView*)tableView endLoadMoreHandler:(FooterLoadMoreHandler)endLoadMoreHandler lastSectionVo:(SectionVo*)lastSectionVo; @optional /** 当GYTableBaseView下拉刷新完毕后代理调用 **/ -(void)didRefreshComplete:(GYTableBaseView*)tableView; @optional /** 当GYTableBaseView上拉加载完毕后代理调用 **/ -(void)didLoadMoreComplete:(GYTableBaseView*)tableView; @optional /** 当GYTableBaseView某一条GYTableViewCell实例被点击时代理调用 **/ -(void)tableView:(GYTableBaseView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath; @optional /** 当GYTableBaseView滚动到某个位置时代理调用 **/ -(void)didScrollToRow:(GYTableBaseView*)tableView indexPath:(NSIndexPath *)indexPath; @optional /** 当GYTableBaseView从滚动状态静止时代理调用 **/ -(void)didEndScrollingAnimation:(GYTableBaseView*)tableView; @end
GYTableViewCell.h
@interface GYTableViewCell : UITableViewCell /** 是否在该节中的首位 **/ @property(nonatomic,assign) BOOL isFirst; /** 是否在该节中的末位 **/ @property(nonatomic,assign) BOOL isLast; /** 是否在该节只有此一个GYTableViewCell实例 **/ @property(nonatomic,assign) BOOL isSingle; /** 是否需要刷新界面 **/ @property(nonatomic,assign) BOOL needRefresh; /** 是否初次界面加载完毕 **/ @property(nonatomic,assign) BOOL isSubviewShow; /** 对应的GYTableBaseView实例 **/ @property(nonatomic,weak) GYTableBaseView* tableView; /** 当前所处位置 **/ @property(nonatomic,retain) NSIndexPath* indexPath; /** 对应的CellVo实例 **/ @property(nonatomic,retain) CellVo *cellVo; /** 页面元素创建并布局 请勿使用layoutSubviews来布局 **/ -(void)showSubviews; /** 是否显示选中的效果样式 默认false **/ -(BOOL)showSelectionStyle; /** 根据内容动态计算高度(适合内容多少高度不定的样式或文案展示) **/ -(CGFloat)getCellHeight:(CGFloat)cellWidth; /** 检查cellData的类型是否是目标类型 并返回cellData **/ -(id)checkCellDataClass:(Class)targetClass; /** 检查cellData类型为NSArray中的子元素类型是否是目标类型 并返回cellData **/ -(NSArray*)checkCellArrayDataClass:(Class)arrayMemberClass; @end
GYTableViewSection.h
@interface GYTableViewSection : UIControl /** tableView所有section总数 **/ @property(nonatomic,assign)NSInteger sectionCount; /** 当前section索引位置 **/ @property(nonatomic,assign)NSInteger sectionIndex; /** 外部传入的数据 用来布局或交互等(sectionVo.sectionData) **/ @property(nonatomic,retain)id data; /** 是否在整个tableView的首位 **/ @property(nonatomic,assign)BOOL isFirst; /** 是否在整个tableView的末位 **/ @property(nonatomic,assign)BOOL isLast; @end
添加Cell
列表控制器内部实现
-(void)headerRefresh:(GYTableBaseView *)tableView endRefreshHandler:(HeaderRefreshHandler)endRefreshHandler{ [tableView addSectionVo:[SectionVo initWithParams:^(SectionVo *svo) { //添加一个高度为230,类型为BannerViewCell,展示banner图片列表的Cell [svo addCellVo:[CellVo initWithParams:230 cellClass:RefreshBannerViewCell.class cellData:self.bannerUrlGroup]]; }]]; endRefreshHandler(YES);//不要忘了结束刷新,否则刷新动画会停留原地 }
批量添加Cell
列表控制器内部实现
-(void)headerRefresh:(GYTableBaseView *)tableView endRefreshHandler:(HeaderRefreshHandler)endRefreshHandler{ [tableView addSectionVo:[SectionVo initWithParams:^(SectionVo *svo) { //添加一个高度为230,类型为BannerViewCell,展示banner图片列表的Cell [svo addCellVo:[CellVo initWithParams:230 cellClass:RefreshBannerViewCell.class cellData:self.bannerUrlGroup]]; }]]; //注意banner和基金产品列表属于不同区域,应存放到各自section中添加,管理section视图会比较方便 [tableView addSectionVo:[SectionVo initWithParams:^(SectionVo *svo) { //添加多个高度为80,类型为RefreshFundViewCell,展示基金信息的Cell [svo addCellVo:[CellVo initWithParams:80 cellClass:RefreshFundViewCell.class cellData:self.fundModels[0]]]; [svo addCellVo:[CellVo initWithParams:80 cellClass:RefreshFundViewCell.class cellData:self.fundModels[1]]]; [svo addCellVo:[CellVo initWithParams:80 cellClass:RefreshFundViewCell.class cellData:self.fundModels[2]]]; //... }]]; endRefreshHandler(YES);//不要忘了结束刷新,否则刷新动画会停留原地 }
相同类型的Cell添加可以修改成通过原数组批量添加
[tableView addSectionVo:[SectionVo initWithParams:^(SectionVo *svo) { //添加多个高度为80,类型为RefreshFundViewCell,展示基金信息的Cell [svo addCellVoByList:[CellVo dividingCellVoBySourceArray:80 cellClass:RefreshFundViewCell.class sourceArray:self.fundModels]]; }]];
添加Section
如果一节内容需要添加section页眉视图,只要在sectionVo实例设置sectionHeaderClass即可,同理section页脚设置sectionFooterClass
-(void)headerRefresh:(GYTableBaseView *)tableView endRefreshHandler:(HeaderRefreshHandler)endRefreshHandler{ [tableView addSectionVo:[SectionVo initWithParams:36 sectionHeaderClass:RefreshFundViewSection.class sectionHeaderData:@"精品专区" nextBlock:^(SectionVo *svo) { //添加section内的cell... }]]; endRefreshHandler(YES); }
分类结构如下
isUnique唯一性
默认所有相同Class的Cell实例都是相互复用,每次下拉刷新或者table设置reloadData,被复用的Cell实例都会重新触发刷新调用showSubviews,从而根据传递的data展开;然而,一些特殊的Cell不需要复用或只实例化一次,比如标签按钮区域的Cell或者banner区域的Cell,每次下拉都是只用这个实例,可以设置为isUnique作为唯一Cell实例优化提高性能
-(void)headerRefresh:(GYTableBaseView *)tableView endRefreshHandler:(HeaderRefreshHandler)endRefreshHandler{ [tableView addSectionVo:[SectionVo initWithParams:^(SectionVo *svo) { //添加一个高度为230,类型为BannerViewCell,展示banner图片列表的Cell [svo addCellVo:[CellVo initWithParams:230 cellClass:RefreshBannerViewCell.class cellData:self.bannerUrlGroup isUnique:YES]]; //添加一个高度为90,类型为RefreshHotViewCell,展示banner图片列表的Cell [svo addCellVo:[CellVo initWithParams:90 cellClass:RefreshHotViewCell.class cellData:self.hotModels isUnique:YES]]; }]]; endRefreshHandler(YES); }
调用上拉加载
列表控制器内部设置显示上拉加载控制器
-(BOOL)isShowFooter{ return YES; }
列表控制器内部重写footerLoadMore
//endLoadMoreHandler:结束刷新回调block,lastSectionVo:上一节sectionVo数据,即当前列表页最后一节 -(void)footerLoadMore:(GYTableBaseView *)tableView endLoadMoreHandler:(FooterLoadMoreHandler)endLoadMoreHandler lastSectionVo:(SectionVo *)lastSectionVo{ [lastSectionVo addCellVoByList:[CellVo dividingCellVoBySourceArray:80 cellClass:RefreshFundViewCell.class sourceArray:self.fundNewModels]];//将新增的CellVo实例继续添加到上一节SectionVo实例中 endLoadMoreHandler(YES); }
根据需求添加到列表页最后一节,或者添加到新的一节数据中,并设置添加上限
if([tableView getTotalCellVoCount] > 30){//总共超出30条数据不添加数据 endLoadMoreHandler(NO);//直接结束上拉加载刷新,并显示"已经全部加载完毕" return; } //根据业务需求的不同,可以继续添加到上一节sectionVo,也可以添加到新的一节sectionVo中 if([lastSectionVo getCellVoCount] < 15){//上一节少于15条继续添加到上一节sectionVo [lastSectionVo addCellVoByList:[CellVo dividingCellVoBySourceArray:80 cellClass:RefreshFundViewCell.class sourceArray:self.fundNewModels]]; }else{//上一节超了 添加到新的一节sectionVo [tableView addSectionVo:[SectionVo initWithParams:36 sectionHeaderClass:RefreshFundViewSection.class sectionHeaderData:@"推荐专区" nextBlock:^(SectionVo *svo) { [svo addCellVoByList:[CellVo dividingCellVoBySourceArray:80 cellClass:RefreshFundViewCell.class sourceArray:self.fundNewModels]]; }]]; } endLoadMoreHandler(YES);//不要忘了结束上拉加载刷新
更改UITableView的frame
列表控制器内部重写getTableViewFrame
如存在和容器底部对齐的元素,请在此方法对齐底部位置(默认占满controller边界);autoLayerout无需重写此方法,自行设置tableView和其他元素布局关系
-(CGRect)getTableViewFrame{ self.noticeBack.frame = CGRectMake(0, 0, self.view.width, 30); self.submitButton.maxY = self.view.height;//底部按钮对齐容器底部 //返回设置好的tableView位置frame 高度=总高度-公告区高-底部按钮高 return CGRectMake(0, self.noticeBack.height, self.view.width, self.view.height - self.noticeBack.height - self.submitButton.height); }
自定义下拉刷新控件
列表控制器内部重写getRefreshHeader
-(MJRefreshHeader *)getRefreshHeader{ return [[DiyRotateRefreshHeader alloc]init]; }
侦听选中的Cell
列表控制器内部实现代理,原生的方法一致
-(void)tableView:(GYTableBaseView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{ CellVo* cvo = [tableView getCellVoByIndexPath:indexPath];//获取到绑定的CellVo XXClass* cellData = cvo.cellData;//获得cell的原始数据 //根据数据添加业务逻辑... }
设置cell点击效果,cell实例内部重写showSelectionStyle
-(BOOL)showSelectionStyle{ return YES; }
设置Cell或Section元素间距
列表控制器内部设置tableView属性cellGap或sectionGap
- (void)viewDidLoad { self.tableView.sectionGap = 6;//设置每一节区域之间间距 self.tableView.cellGap = 3;//设置每个Cell之间间距(包含每一节区域) }
设置选中某个位置的Cell
当刷新完成后设置,列表控制器内部设置tableView属性selectedIndexPath
-(void)headerRefresh:(GYTableBaseView *)tableView endRefreshHandler:(HeaderRefreshHandler)endRefreshHandler{ [tableView addSectionVo:[SectionVo initWithParams:^(SectionVo *svo) { //..添加cell数据 }]]; tableView.selectedIndexPath = [NSIndexPath indexPathForRow:0 inSection:0];//设置选中某个indexPath endRefreshHandler(YES); }
Cell实例设置选中效果,重写setSelected方法,选中样式请根据需求自行添加
-(void)setSelected:(BOOL)selected{ [super setSelected:selected]; [self checkCellRelate];//自定义选中样式方法,非框架内部方法,实现如下 }
Cell实例位置关系isFirst,isLast,位于第一个或最后一个和中间段的Cell样式不同
-(void)checkCellRelate{ if (self.isFirst) { [self drawFirstStyle:nodeColor]; }else if(self.isLast){ [self drawLastStyle:nodeColor]; }else{ [self drawNormalStyle:nodeColor]; } }
设置交互点击某个位置Cell并高亮
- (void)viewDidLoad { self.tableView.clickCellHighlight = YES; }
设置点击Cell自动居中
- (void)viewDidLoad { self.tableView.clickCellMoveToCenter = YES; }
Cell自动调整高度
列表控制器内部设置CellVo传入高度CELL_AUTO_HEIGHT
-(void)headerRefresh:(GYTableBaseView *)tableView endRefreshHandler:(HeaderRefreshHandler)endRefreshHandler{ [tableView addSectionVo:[SectionVo initWithParams:^(SectionVo *svo) { [svo addCellVoByList:[CellVo dividingCellVoBySourceArray:CELL_AUTO_HEIGHT cellClass:AutoHeightWeiboCell.class sourceArray:self.weiboModels]]; }]]; endRefreshHandler(YES); }
Cell实例重写getCellHeight方法获取动态高度,获取高度内容会被缓存不会二次计算
-(CGFloat)getCellHeight:(CGFloat)cellWidth{ WeiboModel* weiboModel = GET_CELL_DATA(WeiboModel.class);//获取Model NSString* content = weiboModel.content;//获取动态内容字符串 CGRect contentSize = [content boundingRectWithSize:CGSizeMake(cellWidth - LEFT_PADDING - RIGHT_PADDING, FLT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:[UIFont systemFontOfSize:SIZE_TEXT_SECONDARY]} context:nil];//计算给定范围内最佳尺寸 return TOPIC_AREA_HEIGHT + contentSize.size.height + IMAGE_AREA_HEIGHT + BOTTOM_PADDING * 2;//返回计算后的最终高度 }
上一篇: 如何正确显示数据库中的图片
下一篇: 春季特别注意排毒 多吃五蔬菜排毒降火