2025-04-09 18:24:58 +08:00

683 lines
28 KiB
Swift
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.

//
// JYPageMenuView.swift
// JYPageController
//
// Created by wang tao on 2022/7/14.
//
import UIKit
@objc public protocol JYSegmentedViewDataSource {
///segmentedViewitems. segmentedView items count
func numberOfSegmentedViewItems() -> Int
///segmentedViewitem. segmentedView item title
func segmentedView(_ segmentedView: JYSegmentedView, titleAt index: Int) -> String
///segmentedViewitem,UIViewindextitle
/// index=1return button segmentedView(_ segmentedView: JYSegmentedView, titleAt index: Int)titleindex=1
///viewSize
///CustomView has higher priority than titlewhen return customView, ignore title. CustomView need set frame.size
@objc optional func segmentedView(_ segmentedView: JYSegmentedView, customViewAt index: Int) -> UIView?
///segmentedView item returnUIViewframe.size
///item badgeView (eg. label/red dot/icon, need set frame.size)
@objc optional func segmentedView(_ segmentedView: JYSegmentedView, badgeViewAt index: Int) -> UIView?
}
@objc public protocol JYSegmentedViewDelegate {
@objc optional func segmentedView(_ segmentedView: JYSegmentedView, didSelectItemAt index: Int)
}
public class JYSegmentedView: UIView {
/// config
var config: JYPageConfig = JYPageConfig()
/// delegate
weak public var delegate: JYSegmentedViewDelegate?
/// datasource
weak public var dataSource: JYSegmentedViewDataSource? {
didSet {
getItemsCount()
}
}
///index
private var selectedIndex: Int = 0
///item
private var itemsCount: Int = 0
///items
private var items = [JYSegmentedViewItem]()
///item
private var itemBackgroundViews = [UIImageView]()
///reloadlayoutSubviewlayoutItemsitemtransformtranformframe
var layoutOnceToken: Bool = false
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = true
addSubview(contentView)
contentView.addSubview(indicator)
}
public convenience init(pageConfig: JYPageConfig) {
self.init()
config = pageConfig
if config.indicatorStyle == .singleLine {
indicator.backgroundColor = config.indicatorColor
indicator.layer.cornerRadius = config.indicatorCornerRadius
}
if config.indicatorStyle == .none {
indicator.removeFromSuperview()
}
if config.indicatorStyle == .customView {
indicator.removeFromSuperview()
indicator = config.customIndicator ?? UIView()
contentView.addSubview(indicator)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func willMove(toSuperview newSuperview: UIView?) {
super.willMove(toSuperview: newSuperview)
if newSuperview != nil {
reload()
}
}
public override func layoutSubviews() {
super.layoutSubviews()
contentView.frame = self.bounds
layoutItems()
indicatorMoveTo(index: selectedIndex, animate: false)
resetHorContentOffset(animate: false)
}
private func getItemsCount() {
guard let source = dataSource else {
return
}
itemsCount = source.numberOfSegmentedViewItems()
}
//MARK: - Public
///使segmentedView(reloadindexselect)
public func reload() {
guard let source = dataSource, source.numberOfSegmentedViewItems() > 0 else {
return
}
getItemsCount()
addItems()
layoutOnceToken = false
layoutItems()
resetHorContentOffset(animate: false)
indicatorMoveTo(index: selectedIndex, animate: false)
contentView.sendSubviewToBack(indicator)
itemBackgroundViews.forEach { contentView.sendSubviewToBack($0) }
}
///index
public func currentSelectedIndex() -> Int {
return selectedIndex
}
///segmentedviewcontentsize
public func contentSize() -> CGSize {
return contentView.contentSize
}
////segmentedviewframe
public func updateFrame(frame: CGRect) {
self.frame = frame
layoutIfNeeded()
resetHorContentOffset(animate: false)
}
///indexitembadgeView
public func addSegmentedItemBadgeView(_ badgeView: UIView, atIndex index: Int) {
guard let item = itemWithIndex(index) else {
return
}
if item.hasBadgeView {
item.badgeView?.removeFromSuperview()
}
item.badgeView = badgeView
contentView.addSubview(badgeView)
updateItemsFrame()
}
///indexitembadgeView
public func removeSegmentedItemBadgeView(atIndex index: Int) {
guard let menuItem = itemWithIndex(index) else {
return
}
if menuItem.hasBadgeView {
menuItem.badgeView?.removeFromSuperview()
menuItem.badgeView = nil
updateItemsFrame()
}
}
///
public func segmentedViewScroll(by pageView: UIScrollView) {
changeItemsByScrollViewDidScroll(scrollView: pageView)
updateItemsFrame()
}
///scrollVIewscrollEndDecelerating
public func segmentedViewScrollEnd(byScrollEndDecelerating pageView: UIScrollView) {
let offsetX = pageView.contentOffset.x
let currentIndex = Int(offsetX/pageView.frame.width)
selectedIndex = currentIndex
itemWithIndex(selectedIndex)?.selected = true
itemWithIndex(selectedIndex)?.font = UIFont.systemFont(ofSize: config.normalTitleFont, weight: config.selectedTitleFontWeight)
for item in items {
if (item.tag - kMenuItemTagExtenValue) != selectedIndex {
item.selected = false
item.font = UIFont.systemFont(ofSize: config.normalTitleFont, weight: config.normalTitleFontWeight)
}
}
resetHorContentOffset(animate: true)
indicatorMoveTo(index: selectedIndex, animate: true)
}
///index
public func select(_ index: Int) {
if index < itemsCount {
itemWithIndex(index)?.selected = true
selectedIndex = index
reload()
delegate?.segmentedView?(self, didSelectItemAt: selectedIndex)
}
}
//MARK: - Private
///item
private func changeItemsByScrollViewDidScroll(scrollView: UIScrollView) {
if scrollView.frame.size.width <= 0 {
return
}
let offsetX = scrollView.contentOffset.x
let rate = offsetX.truncatingRemainder(dividingBy: scrollView.frame.size.width)/scrollView.frame.size.width
if rate <= 0 {
return
}
var selectedItem = itemWithIndex(selectedIndex)
var targetItem: JYSegmentedViewItem?
if offsetX > CGFloat(selectedIndex) * scrollView.frame.size.width {
let targetIndex = Int(offsetX/scrollView.frame.size.width) + 1
targetItem = itemWithIndex(targetIndex)
//:selectedIndexfromItemtoItem
if selectedIndex != Int(offsetX/scrollView.frame.size.width) {
itemWithIndex(selectedIndex)?.transform = .identity
if let item = targetItem {
item.transform = CGAffineTransform(scaleX:maxScale, y: maxScale)
}
selectedIndex = Int(offsetX/scrollView.frame.size.width)
selectedItem = itemWithIndex(selectedIndex)
resetHorContentOffset(animate: true)
updateItemColor()
}
}else{
let targetIndex = Int(offsetX/scrollView.frame.size.width)
targetItem = itemWithIndex(targetIndex)
//:selectedIndexfromItemtoItem
if selectedIndex != Int(offsetX/scrollView.frame.size.width) + 1 {
itemWithIndex(selectedIndex)?.transform = .identity
if let item = targetItem {
item.transform = CGAffineTransform(scaleX:maxScale , y: maxScale)
}
selectedIndex = Int(offsetX/scrollView.frame.size.width) + 1
selectedItem = itemWithIndex(selectedIndex)
resetHorContentOffset(animate: true)
updateItemColor()
}
}
guard let toItem = targetItem, let fromItem = selectedItem else {
return
}
if fromItem.tag < toItem.tag {
fromItem.rate = 1 - rate
toItem.rate = rate
let fromItemCurrentScaleX = maxScale - (maxScale - 1) * rate
let fromItemCurrentScaleY = maxScale - (maxScale - 1) * rate
let toItemCurrentScaleX = 1 + (maxScale - 1) * rate
let toItemCurrentScaleY = 1 + (maxScale - 1) * rate
fromItem.transform = CGAffineTransform(scaleX: fromItemCurrentScaleX, y: fromItemCurrentScaleY)
toItem.transform = CGAffineTransform(scaleX: toItemCurrentScaleX, y: toItemCurrentScaleY)
}else {
fromItem.rate = rate
toItem.rate = 1 - rate
let fromItemCurrentScaleX = maxScale - (maxScale - 1) * (1 - rate)
let fromItemCurrentScaleY = maxScale - (maxScale - 1) * (1 - rate)
let toItemCurrentScaleX = 1 + (maxScale - 1) * (1 - rate)
let toItemCurrentScaleY = 1 + (maxScale - 1) * (1 - rate)
fromItem.transform = CGAffineTransform(scaleX: fromItemCurrentScaleX, y: fromItemCurrentScaleY)
toItem.transform = CGAffineTransform(scaleX: toItemCurrentScaleX, y: toItemCurrentScaleY)
}
if config.indicatorStyle != .none {
indicatorMove(fromItem: fromItem, toItem: toItem, offsetX: offsetX, rate: rate, pageView: scrollView)
}
}
private func updateItemColor() {
items.forEach { item in
if item.tag == selectedIndex + kMenuItemTagExtenValue {
item.selected = true
}else{
item.selected = false
}
}
}
private func indicatorMove(fromItem: JYSegmentedViewItem, toItem: JYSegmentedViewItem, offsetX: CGFloat, rate: CGFloat, pageView: UIScrollView) {
let scrollViewWidth = pageView.frame.width
var currentIndicatorWidth: CGFloat = config.indicatorWidth > 0 ? config.indicatorWidth : fromItem.frame.width
let indicatorMaxWidth = abs(toItem.center.x - fromItem.center.x)
let tempOffetX = offsetX.truncatingRemainder(dividingBy: scrollViewWidth)
switch config.indicatorStyle {
case .singleLine:
if config.indicatorStickyAnimation {//线
if tempOffetX <= scrollViewWidth/2 {
let percent_min_max = tempOffetX/scrollViewWidth*2
currentIndicatorWidth = currentIndicatorWidth + percent_min_max * (indicatorMaxWidth - currentIndicatorWidth)
}else{
let percent_max_min = (tempOffetX - scrollViewWidth/2)/scrollViewWidth*2
currentIndicatorWidth = currentIndicatorWidth + (1 - percent_max_min)*(indicatorMaxWidth - currentIndicatorWidth)
}
if fromItem.tag < toItem.tag {
indicator.center = CGPoint(x: fromItem.center.x + rate * indicatorMaxWidth, y: indicator.center.y)
}else{
indicator.center = CGPoint(x: fromItem.center.x - (1 - rate) * indicatorMaxWidth, y: indicator.center.y)
}
var frame = indicator.frame
frame.size.width = currentIndicatorWidth
frame.size.height = indicator.frame.size.height
indicator.frame = frame
}else {
if fromItem.tag < toItem.tag {
indicator.center = CGPoint(x: fromItem.center.x + rate * indicatorMaxWidth, y: indicator.center.y)
}else{
indicator.center = CGPoint(x: fromItem.center.x - (1 - rate) * indicatorMaxWidth, y: indicator.center.y)
}
var frame = indicator.frame
frame.size.width = currentIndicatorWidth
frame.size.height = indicator.frame.size.height
indicator.frame = frame
}
case .customView:
if fromItem.tag < toItem.tag {
indicator.center = CGPoint(x: fromItem.center.x + rate * indicatorMaxWidth, y: indicator.center.y)
}else{
indicator.center = CGPoint(x: fromItem.center.x - (1 - rate) * indicatorMaxWidth, y: indicator.center.y)
}
default: break
}
}
private func addItems() {
guard let source = dataSource else{
return
}
for item in items {
item.removeFromSuperview()
item.badgeView?.removeFromSuperview()
}
items.removeAll()
itemBackgroundViews.forEach { $0.removeFromSuperview() }
itemBackgroundViews.removeAll()
for i in 0 ..< itemsCount {
let bgView = UIImageView()
bgView.layer.masksToBounds = true
contentView.addSubview(bgView)
itemBackgroundViews.append(bgView)
let customView = source.segmentedView?(self, customViewAt: i)
var item = JYSegmentedViewItem()
if let customItem = customView {
item = JYSegmentedViewItem(customItemView: customItem)
}else{
let title = source.segmentedView(self, titleAt: i)
item = JYSegmentedViewItem(text: title)
}
item.tag = i + kMenuItemTagExtenValue
item.config = config
item.delegate = self
contentView.addSubview(item)
items.append(item)
if let badgeView = source.segmentedView?(self, badgeViewAt: i) {
item.badgeView = badgeView
contentView.addSubview(badgeView)
}
}
}
///
private func indicatorMoveTo(index: Int, animate: Bool) {
guard let menuItem = itemWithIndex(selectedIndex), config.indicatorStyle != .none, itemsCount > 0 else {
return
}
var indicatorRect: CGRect = .zero
if config.indicatorStyle == .singleLine || config.indicatorStyle == .customView {
if config.indicatorWidth > 0 {//
indicatorRect = CGRect(x: (menuItem.frame.width - config.indicatorWidth)/2 + menuItem.frame.origin.x, y: frame.height - config.indicatorBottom - config.indicatorHeight, width: config.indicatorWidth, height: config.indicatorHeight)
}else {
indicatorRect = CGRect(x: CGRectGetMinX(menuItem.frame), y: frame.height - config.indicatorBottom - config.indicatorHeight, width: menuItem.frame.width, height: config.indicatorHeight)
}
}
// else if config.indicatorStyle == .customView {
// if let indicator = config.customIndicator {
// indicatorRect = CGRect(x: (menuItem.frame.width - indicator.frame.width)/2 + menuItem.frame.origin.x, y: frame.height - config.indicatorBottom - indicator.frame.height, width: indicator.frame.width, height: indicator.frame.height)
// }
// }
var duration: Double = 0
if animate {
duration = kMenuItemAnimateDuration
}
UIView.animate(withDuration: duration, delay: 0, options: .curveEaseInOut, animations: {
self.indicator.frame = indicatorRect
}, completion: nil)
}
///itemcontentOffsetX
private func resetHorContentOffset(animate: Bool) {
guard let selectedItem = itemWithIndex(selectedIndex) else {
return
}
let contentSize = contentView.contentSize
let width = contentView.frame.size.width
if contentSize.width > width {
//itemscrollView
let itemCenterX = selectedItem.center.x
if itemCenterX < width/2 {
contentView.setContentOffset(CGPoint(x: 0, y: 0), animated: animate)
}else{
if (contentSize.width - itemCenterX) > width/2 {
//item
let itemCenterByScreen = selectedItem.frame.origin.x - contentView.contentOffset.x + selectedItem.frame.size.width/2
let itemCenterToContentCenterDis = width/2 - itemCenterByScreen
contentView.setContentOffset(CGPoint(x:contentView.contentOffset.x - itemCenterToContentCenterDis, y: 0), animated: animate)
}else{
contentView.setContentOffset(CGPoint(x:contentSize.width - width, y: 0), animated: animate)
}
}
}
}
private func layoutItems() {
var totalWidth: CGFloat = 0
if frame.size.height > 1, frame.size.width > 1, layoutOnceToken == false {
for (index,item) in items.enumerated() {
//itemWidthmaxregular->medium medium->regular
let selectedItemWidth = sizeForItem(item, font: UIFont.systemFont(ofSize: config.normalTitleFont, weight: config.selectedTitleFontWeight)).width
let normalItemWidth = sizeForItem(item, font: UIFont.systemFont(ofSize: config.normalTitleFont, weight: config.normalTitleFontWeight)).width
var itemWidth = max(normalItemWidth,selectedItemWidth) + config.textMargin * 2
var itemHeight = sizeForItem(item, font: UIFont.systemFont(ofSize: config.normalTitleFont, weight: config.selectedTitleFontWeight)).height
if item.type == .customView {
itemWidth = item.customView?.frame.size.width ?? 0
itemHeight = item.customView?.frame.size.height ?? 0
}
if index == 0 {
item.frame = CGRect(x: config.leftPadding, y: config.itemTop ?? (frame.size.height - itemHeight)/2, width: itemWidth, height: itemHeight)
totalWidth = totalWidth + itemWidth + config.leftPadding
}else{
item.frame = CGRect(x: totalWidth + config.itemsMargin, y:config.itemTop ?? (frame.size.height - itemHeight)/2, width: itemWidth, height: itemHeight)
totalWidth = totalWidth + itemWidth + config.itemsMargin
}
if index == selectedIndex {
item.transform = CGAffineTransform(scaleX: maxScale, y: maxScale)
item.textColor = config.selectedTitleColor
item.font = UIFont.systemFont(ofSize: config.normalTitleFont, weight: config.selectedTitleFontWeight)
}else{
item.transform = .identity
item.font = normalFont
item.textColor = config.normalTitleColor
}
let bgView = self.itemBackgroundViews[index]
bgView.backgroundColor = config.itemBackgroundColor
if config.itemBackgroundHeight > 0 {
bgView.frame = CGRect(x: item.frame.origin.x, y: item.frame.origin.y, width: item.frame.size.width, height: config.itemBackgroundHeight)
} else {
bgView.frame = item.frame
}
bgView.center = item.center
bgView.layer.cornerRadius = config.itemBackgroundCornerRadius
}
///: item.transformframe, itemMargin, contentsize
updateItemsFrame()
layoutOnceToken = true
}
}
///itemsframe
private func updateItemsFrame() {
let width = calculateTotalWidth()
var startX: CGFloat = config.leftPadding
if config.alignment == .center, width < frame.width {
startX = (frame.width - width)/2 + config.leftPadding
}
if config.alignment == .right, width < frame.width {
startX = frame.width - width
}
var totalWidth: CGFloat = 0
for (index,item) in items.enumerated() {
if index == 0 {
item.frame = CGRect(x: startX , y: item.frame.origin.y, width: item.frame.width, height: item.frame.height)
item.badgeView?.frame = CGRect(x: item.frame.width + config.badgeViewOffset.x, y: item.frame.origin.y - item.badgeViewHeight/2 + config.badgeViewOffset.y, width: item.badgeViewWidth, height: item.badgeViewHeight)
totalWidth = startX + item.frame.width
}else{
item.frame = CGRect(x: totalWidth + config.itemsMargin , y: item.frame.origin.y, width: item.frame.width, height: item.frame.height)
item.badgeView?.frame = CGRect(x: item.frame.width + item.frame.origin.x + config.badgeViewOffset.x, y: item.frame.origin.y - item.badgeViewHeight/2 + config.badgeViewOffset.y, width: item.badgeViewWidth, height: item.badgeViewHeight)
totalWidth = totalWidth + item.frame.width + config.itemsMargin
}
let bgView = self.itemBackgroundViews[index]
bgView.backgroundColor = config.itemBackgroundColor
if config.itemBackgroundHeight > 0 {
bgView.frame = CGRect(x: item.frame.origin.x, y: item.frame.origin.y, width: item.frame.size.width, height: config.itemBackgroundHeight)
} else {
bgView.frame = item.frame
}
bgView.center = item.center
}
contentView.contentSize = CGSize(width: totalWidth + config.rightPadding, height: frame.size.height)
}
private func calculateTotalWidth() -> CGFloat {
var totalWidth: CGFloat = 0
for (index,item) in items.enumerated() {
if index == 0 {
totalWidth = totalWidth + item.frame.width + item.badgeViewWidth + config.badgeViewOffset.x
}else{
totalWidth = totalWidth + item.frame.width + item.badgeViewWidth + config.itemsMargin + config.badgeViewOffset.x
}
}
return totalWidth
}
private func itemWithIndex(_ index: Int) -> JYSegmentedViewItem? {
if let item = contentView.viewWithTag(index+kMenuItemTagExtenValue) as? JYSegmentedViewItem {
return item
}
return nil
}
//MARK: - Lazy
private lazy var contentView : UIScrollView = {
let scrollView = UIScrollView.init()
scrollView.showsHorizontalScrollIndicator = false
scrollView.bounces = false
if #available(iOS 11.0, *) {
scrollView.contentInsetAdjustmentBehavior = .never
}
return scrollView
}()
private lazy var indicator: UIView = {
let view = UIView()
view.backgroundColor = config.indicatorColor
view.layer.cornerRadius = config.indicatorCornerRadius
return view
}()
private lazy var maxScale: CGFloat = {
return config.selectedTitleFont/config.normalTitleFont
}()
private lazy var normalFont: UIFont = {
let font = UIFont.systemFont(ofSize: config.normalTitleFont, weight: config.normalTitleFontWeight)
return font
}()
private lazy var selectedFont: UIFont = {
let font = UIFont.systemFont(ofSize: config.selectedTitleFont, weight: config.selectedTitleFontWeight)
return font
}()
//MARK: - Contant
private let kMenuItemTagExtenValue: Int = 1000000
private let kMenuItemAnimateDuration: Double = 0.3
}
//MARK: - JYSegmentedViewItemDelegate - itemextension
extension JYSegmentedView: JYSegmentedViewItemDelegate {
func segmentedItemDidSelected(_ item: JYSegmentedViewItem) {
let targetIndex = item.tag - kMenuItemTagExtenValue
segmentedViewSelectedItemChange(fromIndex: selectedIndex, toIndex: targetIndex)
selectedIndex = targetIndex
indicatorMoveTo(index: targetIndex, animate: true)
delegate?.segmentedView?(self, didSelectItemAt: targetIndex)
}
private func segmentedViewSelectedItemChange(fromIndex: Int, toIndex: Int) {
guard let fromItem = itemWithIndex(fromIndex), let toItem = itemWithIndex(toIndex) else {
return
}
toItem.selected = true
fromItem.selected = false
var rate: CGFloat = 0
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
timer.schedule(deadline: .now(), repeating: kMenuItemAnimateDuration/100)
timer.setEventHandler(handler: {
rate = rate + 0.01
fromItem.rate = 1 - rate
toItem.rate = rate
})
timer.resume()
UIView.animate(withDuration: kMenuItemAnimateDuration, delay: 0, options: .curveEaseInOut, animations: {
fromItem.textColor = self.config.normalTitleColor
toItem.textColor = self.config.selectedTitleColor
fromItem.transform = CGAffineTransform(scaleX: 1, y: 1)
toItem.transform = CGAffineTransform(scaleX: self.maxScale, y: self.maxScale)
self.updateItemsFrame()
}) { finished in
timer.cancel()
toItem.textColor = self.config.selectedTitleColor
toItem.font = UIFont.systemFont(ofSize: self.config.normalTitleFont, weight: self.config.selectedTitleFontWeight)
fromItem.textColor = self.config.normalTitleColor
fromItem.font = self.normalFont
self.resetHorContentOffset(animate: true)
}
}
///item
private func sizeForItem(_ item: JYSegmentedViewItem, font: UIFont) -> CGSize {
let attributes = [NSAttributedString.Key.font: font]
var titleSize: CGSize = NSString(string: item.text ?? "").boundingRect(with: CGSize.init(width: CGFloat(MAXFLOAT), height: CGFloat(MAXFLOAT)), options: .usesLineFragmentOrigin, attributes: attributes, context: nil).size
if titleSize.width < config.itemMinWidth, config.itemMinWidth > 0 {
titleSize = CGSize(width: config.itemMinWidth, height: titleSize.height)
}
if titleSize.width > config.itemMaxWidth, config.itemMaxWidth > 0 {
titleSize = CGSize(width: config.itemMaxWidth, height: titleSize.height)
}
return titleSize
}
}