引言
在现在移动应用中,展示用户特权的UI设计不仅是吸引用户的关键手段,更是提升产品体验的重要部分。特别是在直播场景中,贵族特权作为一种高价值用户身份的象征,通常需要通过精致的页面和流程的交互来突出其重要性和独特性。
为了实现这种展示效果,开发者面临的不仅仅是美观布局的设计,还需要处理复杂的交互逻辑。例如上面的贵族特权展示页面:
- 特权的展示被分为了两个列表,不同列表中的元素大小又不相同。
- 而在进行滑动时有需要上下一一对应。
- 并且在滑动时还需要让每个特权元素都自动停留在屏幕中间位置。
- 另外还有一个特殊效果就是两侧的元素会比中心的元素小上那么一点。
这些效果都对技术实现提出了更高的要求。
本篇博客将以实际项目为例,详细解析贵族特权UI展示效果的设计与实现。从布局分析到交互逻辑的实现,再到细节优化,希望能够为大家实现类似效果提供一些思路。
布局分析
整体结构设计
我们单就贵族页下面的特权展示部分来分析,该部分UI可以分为三个部分:
- 首先是最后一层背景,我们可以直接使用UIImageView来实现。
- 其次是绿色框的部分,我们需要创建一个自定的视图用来展示贵族特权的小图标列表。
- 最后是红色框的部分,我们也来创建一个自定义的视图用来展示贵族特权的大图标和详细信息列表。
分页需求
无论是小的特权图标,还是大的特权卡片都是分页展示的,这就意味着我们创建的滑动视图UIScrollView的isPagingEnabled属性应该为true。而每个列表看上去都是全屏的,如果我们直接设置isPagingEnabled为true显然并不符合要求,所以我们需要按照单个卡片的宽度来进行分页。我们可以通过设置UIScrollView大小为卡片大小的方式并设置UIScrollView的clipsToBounds属性为false来实现这个视觉效果,但是超出部分的触摸事件仍然需要我们手动来处理。
切换效果
当特权卡片进行切换时,它们的切换效果有两个地方需要我们特别注意:
- 中间的特权看起来最大,而两侧的特权会随着距离屏幕中心的距离越大看起来越小(有最小值嗷)。
- 当滑动上半部分的小特权卡片时,每切换一个卡片,下半部分的特权卡片也会切换一页。而切换下半部分时,上半部分的特权列表也会跟着切换。
具体实现
围绕上述的布局以及需求和动态效果的分析,我们来看一下具体的实现过程,其中切换效果可能是一个难点,但是整个设计的核心还是围绕UISCrollView来实现的。
整体结构实现
我们只考虑贵族页的下半部分特权列表,把注意力集中到一个MWNobleBottomView类当中,在它里面来创建整个特权展示功能。
标题
标题我们采用图片和UILabel结合的方式,放到视图的最顶部,超出视图部分,所以需要将MWNobleBottomView的clipsToBounds属性设置为true。
/// 渐变横线
private let lineView = MWGradientView(colors: [.wm_hex("#EDCDAD",0.0),.wm_hex("#EDCDAD"),.wm_hex("#EDCDAD"),.wm_hex("#EDCDAD"),.wm_hex("#EDCDAD",0.0)], direction: .leftToRight)
/// 标题图标
private let titleImageView = UIImageView()
/// 标题
private let titleLabel = UILabel()
private func addLineView() {
self.addSubview(lineView)
lineView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.centerY.equalTo(self.snp.top)
make.height.equalTo(1.0)
}
}
private func addTitle() {
self.addSubview(titleImageView)
titleImageView.image = UIImage(named: "noble_bottom_title")
titleImageView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalTo(lineView)
make.width.equalTo(147.0)
make.height.equalTo(28.0)
}
titleImageView.addSubview(titleLabel)
titleLabel.textColor = .wm_hex("#F3D39F")
titleLabel.font = MWFontHelper.font(name: .nunito, size: 16,weight: .bold)
titleLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
特权小图标列表视图
首先是创建一个特权小图标列表的视图,设置它的约束,并把高度固定:
/// 小图标列表
private let smallPricilegesView = MWNobleSmallPricilegesView()
private func addSmallPricilegesView() {
self.addSubview(smallPricilegesView)
smallPricilegesView.backgroundColor = .cyan.withAlphaComponent(0.7)
smallPricilegesView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.top.equalToSuperview().offset(20.0)
make.height.equalTo(64.0)
}
....
}
特权大卡标列表视图
然后是特权大卡片的列表图示,由于也是固定大小,所以我们也可以直接设置高度:
/// 大图标列表
private let bigPricilegesView = MWNobleBigPricilegesView()
private func addBigPricilegesView() {
self.addSubview(bigPricilegesView)
bigPricilegesView.backgroundColor = .cyan.withAlphaComponent(0.7)
bigPricilegesView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.top.equalTo(smallPricilegesView.snp.bottom).offset(20.0)
make.height.equalTo(311.0 + 42.0)
make.bottom.equalToSuperview()
}
....
}
小箭头
别忘了上下对应的小箭头嗷,我们创建一个UIImageView放到合适的位置即可:
/// 小箭头
private let smallArrow = UIImageView()
private func addSmallArrow() {
self.addSubview(smallArrow)
smallArrow.image = UIImage(named: "noble_arrow")
smallArrow.snp.makeConstraints { make in
make.width.equalTo(28.0)
make.height.equalTo(13.0)
make.centerX.equalToSuperview()
make.top.equalTo(smallPricilegesView.snp.bottom).offset(14.0)
}
}
自定义分页实现
关于自定义的分页的实现项目中采用的方案是使用UISCrollView并设置它的宽度与要现实的单个元素视图相同,并设置它的isPagingEnabled为true,同时借助了UIStackView进行布局。
特权小图标列表分页实现
MWNobleSmallPricilegesView
首先创建了一个左右滑动的UISCrollView以及放置在UISCrollView上的UIStackView。
/// scrollView
let scrollView = UIScrollView()
/// UIStackView
private let stackView = UIStackView()
private func addScrollView() {
self.addSubview(scrollView)
scrollView.clipsToBounds = false
scrollView.delegate = self
scrollView.backgroundColor = .clear
scrollView.isPagingEnabled = true
scrollView.snp.makeConstraints { make in
make.top.equalToSuperview()
make.centerX.equalToSuperview()
make.width.equalTo(64.0 + 36.0)
make.height.equalTo(64.0)
}
}
private func addStackView() {
scrollView.addSubview(stackView)
stackView.backgroundColor = .clear
stackView.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.height.equalTo(64.0)
make.leading.equalToSuperview()
make.trailing.equalToSuperview()
}
stackView.axis = .horizontal
stackView.spacing = 0.0
}
这里需要注意它的宽度和高度,宽度为单元视图宽度+单元视图之间的间距。而高度固定为64。
这么设置之后,UIScrollView会显示全屏的宽度,但是可操作的部分仍然只有64.0+36.0。
接下来我们需要重写系统的hitTest方法,将响应事件转移到UIScrollView上,来保证它的可操作部分与可见部分一致:
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
if view == self {
return scrollView
}
return view
}
特权大卡片列表分页实现
MWNobleBigPricilegesView
大卡列表片实现自定义分页的图标与小图标实现的思路完全相同,我们将UIScrollView设置为固定的宽度,并且将滑动事件扩大到整个屏幕宽。
/// scrollView
let scrollView = UIScrollView()
/// UIStackView
private let stackView = UIStackView()
private func addScrollView() {
self.addSubview(scrollView)
scrollView.clipsToBounds = false
scrollView.delegate = self
scrollView.backgroundColor = .clear
scrollView.isPagingEnabled = true
scrollView.snp.makeConstraints { make in
make.top.equalToSuperview()
make.centerX.equalToSuperview()
make.width.equalTo(277.0 + 23.0)
make.height.equalTo(331.0)
}
}
private func addStackView() {
scrollView.addSubview(stackView)
stackView.backgroundColor = .clear
stackView.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.height.equalTo(331.0)
make.leading.equalToSuperview()
make.trailing.equalToSuperview()
}
stackView.axis = .horizontal
stackView.spacing = 0.0
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
if view == self {
return scrollView
}
return view
}
切换效果实现
我们根据数据在特权列表创建对一个的单元视图,并且设置每个视图的缩放比例来保证视图的起始视觉效果。而要实现视图的切换视觉效果,我们就不得不实现UIScrollViewDelegate协议中的scrollViewDidScroll方法,通过计算每个单元视图距离视图中心的距离,来设置不同的缩放比例。
特权小图列表切换效果实现
我们先来看一下特权小图切换效果的实现,首先根据数据创建列表视图,因为特权的数据较少,所以在这个例子中我们将视图平铺到了UISCrollView。
并将从第二个开始的视图按比例缩小。
/// 渲染数据
/// - Parameter nobleConfigModel: 贵族配置
/// - Parameter nobleInfoModel: 贵族信息
func renderData(nobleConfigModel:MWMineNobleConfigModel,nobleInfoModel:MWNobleInfoModel) {
let nobleItemList = nobleConfigModel.nobleItemList
for i in 0..<nobleItemList.count {
let itemView = MWNobleSmallPricilegesItemView()
stackView.addArrangedSubview(itemView)
let model = nobleItemList[i]
let icon_url = model.indicatorUrl
let url = URL(string: icon_url.validResourceUrl())
itemView.imageView.sd_setImage(with: url)
itemView.tag = 100 + i
itemView.snp.makeConstraints { make in
make.width.height.equalTo(36.0 + 64.0)
}
if i != 0 {
let maxScaleFactor: CGFloat = 54.0/64.0
itemView.transform = CGAffineTransform(scaleX: maxScaleFactor, y: maxScaleFactor)
}
}
}
当UIScrollView开始滑动时,重新设置视图中的所有视图缩放比例。
func scrollViewDidScroll(_ scrollView: UIScrollView) {
...
startScale()
}
/// 开始缩放
@objc private func startScale() {
for view in stackView.arrangedSubviews {
scaleItemView(view as! MWNobleSmallPricilegesItemView)
}
}
private func scaleItemView(_ itemView: MWNobleSmallPricilegesItemView) {
let distanceFromCenter = abs(itemView.center.x - scrollView.contentOffset.x - scrollView.bounds.width / 2)
let maxScaleFactor: CGFloat = 1.0 // 最近时的缩放比例
let minScaleFactor: CGFloat = 54.0 / 64.0 // 最远时的缩放比例
let scaleRange = maxScaleFactor - minScaleFactor
// 距离越远,缩放越接近 minScaleFactor
let scaleFactor = max(minScaleFactor, maxScaleFactor - (distanceFromCenter / scrollView.bounds.width) * scaleRange)
itemView.transform = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)
}
特权大卡列表切换效果实现
大卡列表的实现思路与小卡完全相同,也是在赋值时添加卡片,而滑动时开始设置每一张卡片的缩放比例,只是比例不同,具体实现如下:
/// 渲染数据
/// - Parameter nobleConfigModel: 贵族配置
/// - Parameter nobleInfoModel: 贵族信息
func renderData(nobleConfigModel:MWMineNobleConfigModel,nobleInfoModel:MWNobleInfoModel) {
let nobleItemList = nobleConfigModel.nobleItemList
for i in 0..<nobleItemList.count {
let model = nobleItemList[i]
let itemView = MWNobleBigPricilegesItemView()
stackView.addArrangedSubview(itemView)
itemView.titleLabel.text = model.rightName
itemView.descLabel.text = model.rightDesc
let portraits = model.portrait
/// 示例图标
let index = nobleConfigModel.nobleLevel - 1
if index < portraits.count {
let portrait = portraits[index]
let url = URL(string: portrait.validResourceUrl())
itemView.exampleImageView.sd_setImage(with: url)
}
itemView.tag = 100 + i
itemView.snp.makeConstraints { make in
make.width.equalTo(277.0 + 23.0)
make.height.equalTo(311.0)
}
if i != 0 {
let maxScaleFactor: CGFloat = 262.0/311.0
itemView.transform = CGAffineTransform(scaleX: maxScaleFactor, y: maxScaleFactor)
}
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
...
startScale()
}
/// 开始缩放
@objc private func startScale() {
for view in stackView.arrangedSubviews {
scaleItemView(view as! MWNobleBigPricilegesItemView)
}
}
/// 缩放itemView
private func scaleItemView(_ itemView: MWNobleBigPricilegesItemView) {
let distanceFromCenter = abs(itemView.center.x - scrollView.contentOffset.x - scrollView.bounds.width / 2)
let maxScaleFactor: CGFloat = 1.0 // 最近时的缩放比例
let minScaleFactor: CGFloat = 262.0 / 311.0 // 最远时的缩放比例
let scaleRange = maxScaleFactor - minScaleFactor
// 距离越远,缩放越接近 minScaleFactor
let scaleFactor = max(minScaleFactor, maxScaleFactor - (distanceFromCenter / scrollView.bounds.width) * scaleRange)
itemView.transform = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)
}
切换联动实现
为了实现联动的效果,首先我们需要将每个列表的滑动事件都回调到外面,并同步到另外一个列表。
但是在列表滑动时,当小列表切换一页对应的大卡列表也应该切换一页,因此我们需要将视图的偏移量做一个定比例的映射。
大卡列表UIScrollView滑动的回调
class MWNobleBigPricilegesView: UIView,UIScrollViewDelegate {
....
/// scrollView滑动的回调(带x偏移量)
var scrollViewDidScrollBlock: ((CGFloat) -> Void)?
...
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if let block = scrollViewDidScrollBlock {
block(scrollView.contentOffset.x)
}
....
}
}
然后大卡列表滑动的回调中来使小图列表被动更新UISCrollView的contentoffsetx:
private func addBigPricilegesView() {
...
bigPricilegesView.scrollViewDidScrollBlock = { [weak self] x in
self?.smallPricilegesView.scrollView.contentOffset.x = x * 100.0 / 300.0
}
}
小图列表UIScrollView滑动回调
class MWNobleSmallPricilegesView: UIView,UIScrollViewDelegate {
...
var scrollViewDidScrollBlock: ((CGFloat) -> Void)?
/// 选中的index回调
var didSelectIndexBlock: ((Int) -> Void)?
/// 当前页
private var currentIndex = 0
...
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if let block = scrollViewDidScrollBlock {
block(scrollView.contentOffset.x)
}
if let block = didSelectIndexBlock {
let index = Int(scrollView.contentOffset.x / scrollView.bounds.width)
if index != currentIndex {
block(index)
currentIndex = index
}
}
....
}
}
小图列表里面除了滑动的回调之外,我们还定义了一个页切换的回调用来同步标题。
接下来我们就需要在小图列表滚动的回调中设置大卡列表的偏移量,以及标题:
private func addSmallPricilegesView() {
....
smallPricilegesView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.top.equalToSuperview().offset(20.0)
make.height.equalTo(64.0)
}
smallPricilegesView.scrollViewDidScrollBlock = { [weak self] x in
self?.bigPricilegesView.scrollView.contentOffset.x = x * 300.0 / 100.0
}
// 切面切换
smallPricilegesView.didSelectIndexBlock = { [weak self] index in
guard let self = self else { return }
if let nobleConfigModel = self.nobleConfigModel {
let count = nobleConfigModel.nobleItemList.count
if count == 0 {
return
}
self.titleLabel.text = MWLocaleStringHelper.getString("Privileges") + "\(index+1)/\(count)"
}
}
}
最终效果如下:
结语
通过本篇博客,我们详细解析了贵族特权 UI 展示效果的实现,包括布局设计、分页逻辑、自定义切换效果以及上下部分的联动处理。这些技术点不仅展示了如何实现特定的界面效果,也体现了在复杂需求下平衡性能与交互体验的思路。
尽管这种布局设计是为特定场景量身定制的,但其实现思路与技巧,如非全屏分页的处理、自定义动画效果以及视图联动逻辑,或许可以为你的项目提供新的思路和启发。希望这篇文章能够帮助你更灵活地应对类似的 UI 实现需求。
如果你在实现过程中遇到任何问题,欢迎随时留言交流!