Skip to content

Commit

Permalink
Add scroll bar offset and content insets
Browse files Browse the repository at this point in the history
  • Loading branch information
batanus authored Apr 10, 2023
2 parents 774b1b7 + 2a8b82d commit 8168d6d
Show file tree
Hide file tree
Showing 6 changed files with 123 additions and 25 deletions.
20 changes: 17 additions & 3 deletions DMScrollBar/DMScrollBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,11 @@ public class DMScrollBar: UIView {
scrollIndicatorTopConstraint = scrollIndicator.topAnchor.constraint(equalTo: topAnchor, constant: topOffset)
scrollIndicatorTopConstraint?.isActive = true
}
scrollIndicator.setup(stateConfig: stateConfig, textConfig: indicatorTextConfig)
scrollIndicator.setup(
stateConfig: stateConfig,
textConfig: indicatorTextConfig,
accessibilityIdentifier: configuration.indicator.accessibilityIdentifier
)
}

private func setupAdditionalInfoView() {
Expand Down Expand Up @@ -494,6 +498,10 @@ public class DMScrollBar: UIView {
return panGestureRecognizer?.state.isInactive == true
}

private func scrollIndicatorOffset(forContentOffset contentOffset: CGFloat) -> CGFloat {
return contentOffset + scrollIndicatorTopOffset.y + infoView.frame.height / 2
}

private func startHideTimerIfNeeded() {
guard isPanGestureInactive else { return }
invalidateHideTimer()
Expand All @@ -513,7 +521,10 @@ public class DMScrollBar: UIView {

private func updateAdditionalInfoViewState(forScrollOffset scrollViewOffset: CGFloat, previousOffset: CGFloat?) {
if configuration.infoLabel == nil { return }
guard let offsetLabelText = delegate?.infoLabelText(forOffset: scrollViewOffset) else { return animateAdditionalInfoViewHide() }
guard let offsetLabelText = delegate?.infoLabelText(
forContentOffset: scrollViewOffset,
scrollIndicatorOffset: scrollIndicatorOffset(forContentOffset: scrollViewOffset)
) else { return animateAdditionalInfoViewHide() }
animateAdditionalInfoViewShow()
let direction: CATransitionSubtype? = {
guard let previousOffset else { return nil }
Expand All @@ -533,7 +544,10 @@ public class DMScrollBar: UIView {
}()
scrollIndicator.updateScrollIndicatorText(
direction: direction,
scrollBarLabelText: delegate?.scrollBarText(forOffset: scrollViewOffset),
scrollBarLabelText: delegate?.scrollBarText(
forContentOffset: scrollViewOffset,
scrollIndicatorOffset: scrollIndicatorOffset(forContentOffset: scrollViewOffset)
),
textConfig: textConfig
)
}
Expand Down
39 changes: 36 additions & 3 deletions DMScrollBar/DMScrollBarConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ extension DMScrollBar.Configuration {
/// Top left and bottom left corners will be rounded by a radius equal to half the view's height
public static let roundedLeftCorners = RoundedCorners(radius: .rounded, corners: [.topLeft, .bottomLeft])

/// Top right and bottom right corners will be rounded by a radius equal to half the view's height
public static let roundedRightCorners = RoundedCorners(radius: .rounded, corners: [.topRight, .bottomRight])

/// All corners will be rounded by a radius equal to half the view's height
public static let allRounded = RoundedCorners(radius: .rounded, corners: Set(Corner.allCases))

Expand Down Expand Up @@ -147,24 +150,30 @@ extension DMScrollBar.Configuration {
/// Scroll bar indicator show / hide animation settings
public let animation: Animation

/// Accessibility identifier of the indicator
public let accessibilityIdentifier: String?

/// - Parameters:
/// - normalState: Configuration for indicator state while the user is not interacting with it
/// - activeState: Configuration for indicator state while the user interacting with it
/// - stateChangeAnimationDuration: Time in seconds for the state change animation to take place
/// - insetsFollowsSafeArea: Indicates if safe area insets should be taken into account
/// - animation: Scroll bar indicator show / hide animation settings
/// - accessibilityIdentifier: Accessibility identifier of the indicator
public init(
normalState: StateConfig = .default,
activeState: ActiveStateConfig = .unchanged,
stateChangeAnimationDuration: TimeInterval = 0.3,
insetsFollowsSafeArea: Bool = true,
animation: Animation = .default
animation: Animation = .default,
accessibilityIdentifier: String? = nil
) {
self.normalState = normalState
self.activeState = activeState
self.stateChangeAnimationDuration = stateChangeAnimationDuration
self.insetsFollowsSafeArea = insetsFollowsSafeArea
self.animation = animation
self.accessibilityIdentifier = accessibilityIdentifier
}

/// Default indicator configuration
Expand All @@ -180,35 +189,47 @@ extension DMScrollBar.Configuration {
/// Scroll bar indicator insets
public let insets: UIEdgeInsets

/// Scroll bar indicator content insets
public let contentInsets: UIEdgeInsets

/// Scroll bar image
public let image: UIImage?

/// Scroll bar image size
public let imageSize: CGSize

/// Accessibility identifier of the image
public let imageAccessibilityIdentifier: String?

/// Scroll bar indicator corners which should be rounded
public let roundedCorners: RoundedCorners

/// - Parameters:
/// - size: Size of the scroll bar indicator, which is placed on the right side
/// - backgroundColor: Background color of the scroll bar indicator
/// - insets: Scroll bar indicator insets
/// - contentInsets: Scroll bar indicator content insets
/// - image: Scroll bar image
/// - imageSize: Scroll bar image size. If a nil image is passed - this parameter is ignored
/// - imageAccessibilityIdentifier: Accessibility identifier of the image
/// - roundedCorners: Scroll bar indicator corners which should be rounded
public init(
size: CGSize = CGSize(width: 34, height: 34),
backgroundColor: UIColor = UIColor.defaultScrollBarBackground,
insets: UIEdgeInsets = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0),
contentInsets: UIEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8),
image: UIImage? = UIImage(systemName: "arrow.up.and.down.circle")?.withRenderingMode(.alwaysOriginal).withTintColor(UIColor.systemBackground),
imageSize: CGSize = CGSize(width: 20, height: 20),
imageAccessibilityIdentifier: String? = nil,
roundedCorners: RoundedCorners = .roundedLeftCorners
) {
self.size = size
self.backgroundColor = backgroundColor
self.insets = insets
self.contentInsets = contentInsets
self.image = image
self.imageSize = imageSize
self.imageAccessibilityIdentifier = imageAccessibilityIdentifier
self.roundedCorners = roundedCorners
}

Expand All @@ -221,6 +242,7 @@ extension DMScrollBar.Configuration {
size: .init(width: width, height: 100),
backgroundColor: UIColor.label.withAlphaComponent(0.35),
insets: .init(top: 4, left: 0, bottom: 4, right: 2),
contentInsets: .zero,
image: nil,
roundedCorners: .allRounded
)
Expand All @@ -245,19 +267,24 @@ extension DMScrollBar.Configuration {
public let font: UIFont
/// Text color of the label
public let color: UIColor
/// Accessibility identifier of the label
public let accessibilityIdentifier: String?

/// - Parameters:
/// - insets: Text label insets from
/// - font: Font that should be used for text
/// - color: Text color of the label
/// - accessibilityIdentifier: Accessibility identifier of the label
public init(
insets: UIEdgeInsets,
font: UIFont,
color: UIColor
color: UIColor,
accessibilityIdentifier: String? = nil
) {
self.insets = insets
self.font = font
self.color = color
self.accessibilityIdentifier = accessibilityIdentifier
}
}
}
Expand Down Expand Up @@ -288,6 +315,9 @@ extension DMScrollBar.Configuration {
/// Info label show/hide animation settings
public let animation: Animation

/// Accessibility identifier of the info label
public let accessibilityIdentifier: String?

/// - Parameters:
/// - font: Indicates the font that should be used for info label, which appears during indicator scrolling
/// - textColor: Text color of the info label
Expand All @@ -297,6 +327,7 @@ extension DMScrollBar.Configuration {
/// - maximumWidth: Indicates maximum width of info label. If nil is passed - the info label will grow maximum to the leading side of the screen
/// - roundedCorners: Info label corenrs which should be rounded
/// - animation: Info label show/hide animation settings
/// - accessibilityIdentifier: Accessibility identifier of the info label
public init(
font: UIFont = UIFont.systemFont(ofSize: 13),
textColor: UIColor = UIColor.systemBackground,
Expand All @@ -305,7 +336,8 @@ extension DMScrollBar.Configuration {
textInsets: UIEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10),
maximumWidth: CGFloat? = nil,
roundedCorners: RoundedCorners = .allRounded,
animation: Animation = .default
animation: Animation = .default,
accessibilityIdentifier: String? = nil
) {
self.font = font
self.textColor = textColor
Expand All @@ -315,6 +347,7 @@ extension DMScrollBar.Configuration {
self.maximumWidth = maximumWidth
self.roundedCorners = roundedCorners
self.animation = animation
self.accessibilityIdentifier = accessibilityIdentifier
}

/// Default info label configuration
Expand Down
56 changes: 51 additions & 5 deletions DMScrollBar/DMScrollBarDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@ import UIKit

public protocol DMScrollBarDelegate: AnyObject {
/// This method is triggered every time when scroll bar offset changes while the user is dragging it
/// - Parameter offset: Scroll view content offset
/// - Parameter contentOffset: Scroll view content offset
/// - Parameter scrollIndicatorOffset: Scroll indicator offset
/// - Returns: Text to present in info label (which appears during indicator scrolling to the left of the Scroll Bar) . If returning nil - the info label will not show
func infoLabelText(forOffset offset: CGFloat) -> String?
func infoLabelText(forContentOffset contentOffset: CGFloat, scrollIndicatorOffset: CGFloat) -> String?

/// This method is triggered every time when scroll bar offset changes while the user is dragging it
/// - Parameter offset: Scroll view content offset
/// - Parameter contentOffset: Scroll view content offset
/// - Parameter scrollIndicatorOffset: Scroll indicator offset
/// - Returns: Text to present in scroll bar label. This method is not triggered when Configuration.Indicator.StateConfig.TextConfig is nil.
func scrollBarText(forOffset offset: CGFloat) -> String?
func scrollBarText(forContentOffset contentOffset: CGFloat, scrollIndicatorOffset: CGFloat) -> String?
}

// MARK: - DMScrollBarDelegate extension for Table View

public extension DMScrollBarDelegate {
func scrollBarText(forOffset offset: CGFloat) -> String? { nil }
func infoLabelText(forContentOffset contentOffset: CGFloat, scrollIndicatorOffset: CGFloat) -> String? { nil }

func scrollBarText(forContentOffset contentOffset: CGFloat, scrollIndicatorOffset: CGFloat) -> String? { nil }

/// This is a convenience method to get the header title for the section at the specified content offset. This method will not work for table views with custom headers
/// - Parameters:
Expand Down Expand Up @@ -43,4 +47,46 @@ public extension DMScrollBarDelegate {
return minY...maxY ~= offset
}
}

/// This is a convenience method to get section index for specified collection view content offset
/// - Parameters:
/// - collectionView: Collection view in which the scroll bar is located
/// - offset: Collection View content offset
/// - Returns: Section index in collection view for specified collection view content offset
func sectionIndex(in collectionView: UICollectionView, forOffset offset: CGFloat) -> Int? {
(0..<collectionView.numberOfSections).first { section in
guard let currentSectionOrigin = headerOrigin(in: collectionView, atSection: section) else { return false }
let nextSectionOrigin = headerOrigin(in: collectionView, atSection: section + 1)
let sectionHeight = (nextSectionOrigin?.y ?? .greatestFiniteMagnitude) - currentSectionOrigin.y
let sectionSize = CGSize(width: collectionView.frame.width, height: sectionHeight)
let sectionRect = CGRect(origin: currentSectionOrigin, size: sectionSize)
let minY: CGFloat = section == 0 ? -.greatestFiniteMagnitude : sectionRect.minY
let maxY: CGFloat = section == collectionView.numberOfSections - 1 ?
.greatestFiniteMagnitude :
sectionRect.maxY

return minY...maxY ~= offset
}
}

// MARK: - Private

private func headerOrigin(in collectionView: UICollectionView, atSection section: Int) -> CGPoint? {
let indexPath = IndexPath(item: 0, section: section)
let kind = UICollectionView.elementKindSectionHeader
guard isValid(indexPath: indexPath, in: collectionView) else { return nil }
guard let attributes = collectionView.layoutAttributesForSupplementaryElement(
ofKind: kind,
at: indexPath
) else { return nil }

return attributes.frame.origin
}

private func isValid(indexPath: IndexPath, in collectionView: UICollectionView) -> Bool {
guard indexPath.section < collectionView.numberOfSections else { return false }
let numberOfItems = collectionView.numberOfItems(inSection: indexPath.section)

return indexPath.row < numberOfItems || numberOfItems == 0
}
}
24 changes: 14 additions & 10 deletions DMScrollBar/ScrollBarIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ final class ScrollBarIndicator: UIView {

func setup(
stateConfig: DMScrollBar.Configuration.Indicator.StateConfig,
textConfig: DMScrollBar.Configuration.Indicator.ActiveStateConfig.TextConfig?
textConfig: DMScrollBar.Configuration.Indicator.ActiveStateConfig.TextConfig?,
accessibilityIdentifier: String? = nil
) {
self.accessibilityIdentifier = accessibilityIdentifier
self.isAccessibilityElement = false
backgroundColor = stateConfig.backgroundColor
layer.maskedCorners = stateConfig.roundedCorners.corners.cornerMask
layer.cornerRadius = cornerRadius(
Expand All @@ -54,10 +57,9 @@ final class ScrollBarIndicator: UIView {
centerX.isActive = true
indicatorImageLabelStackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
}
let defaultInset: CGFloat = 8
let leadingInset: CGFloat = {
guard let textConfig else { return 0 }
return stateConfig.image == nil ? textConfig.insets.left : defaultInset
guard let textConfig else { return stateConfig.contentInsets.left }
return stateConfig.image == nil ? textConfig.insets.left : stateConfig.contentInsets.left
}()
setupConstraint(
constraint: &indicatorImageLabelStackViewLeadingConstraint,
Expand All @@ -67,9 +69,9 @@ final class ScrollBarIndicator: UIView {
setupConstraint(
constraint: &indicatorImageLabelStackViewTrailingConstraint,
build: { trailingAnchor.constraint(equalTo: indicatorImageLabelStackView.trailingAnchor, constant: $0) },
value: textConfig != nil ? textConfig?.insets.right ?? defaultInset : 0
value: textConfig?.insets.right ?? stateConfig.contentInsets.right
)
setupIndicatorImageViewState(image: stateConfig.image, size: stateConfig.imageSize)
setupIndicatorImageViewState(config: stateConfig)
setupIndicatorLabelState(config: textConfig)
}

Expand All @@ -87,22 +89,23 @@ final class ScrollBarIndicator: UIView {

// MARK: - Private

private func setupIndicatorImageViewState(image: UIImage?, size: CGSize) {
private func setupIndicatorImageViewState(config: DMScrollBar.Configuration.Indicator.StateConfig) {
buildIndicatorImageViewIfNeeded()
if let image {
if let image = config.image {
indicatorImage?.isHidden = false
indicatorImage?.alpha = 1
indicatorImage?.image = image
indicatorImage?.accessibilityIdentifier = config.imageAccessibilityIdentifier
setupConstraint(
constraint: &indicatorImageWidthConstraint,
build: indicatorImage?.widthAnchor.constraint(equalToConstant:),
value: size.width,
value: config.imageSize.width,
priority: .init(999)
)
setupConstraint(
constraint: &indicatorImageHeightConstraint,
build: indicatorImage?.heightAnchor.constraint(equalToConstant:),
value: size.height
value: config.imageSize.height
)
} else {
indicatorImage?.isHidden = true
Expand All @@ -116,6 +119,7 @@ final class ScrollBarIndicator: UIView {
showIndicatorLabel()
indicatorLabel?.font = config.font
indicatorLabel?.textColor = config.color
indicatorLabel?.accessibilityIdentifier = config.accessibilityIdentifier
indicatorImageLabelStackView.spacing = config.insets.left
} else {
hideIndicatorLabel()
Expand Down
1 change: 1 addition & 0 deletions DMScrollBar/ScrollBarInfoView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ final class ScrollBarInfoView: UIView {
offsetLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -textInsets.bottom).isActive = true
offsetLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: textInsets.left).isActive = true
offsetLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -textInsets.right).isActive = true
offsetLabel.accessibilityIdentifier = config.accessibilityIdentifier

backgroundColor = config.backgroundColor
layer.maskedCorners = config.roundedCorners.corners.cornerMask
Expand Down
8 changes: 4 additions & 4 deletions Example/DMScrollBar/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,13 @@ extension ViewController: UITableViewDataSource {

extension ViewController: DMScrollBarDelegate {
/// In this example, this method returns the section header title for the top visible section
func infoLabelText(forOffset offset: CGFloat) -> String? {
headerTitle(in: tableView, forOffset: offset)
func infoLabelText(forContentOffset contentOffset: CGFloat, scrollIndicatorOffset: CGFloat) -> String? {
headerTitle(in: tableView, forOffset: contentOffset)
}

/// In this example, this method returns the section header title for the top visible section
func scrollBarText(forOffset offset: CGFloat) -> String? {
guard let section = sectionIndex(in: tableView, forOffset: offset) else { return nil }
func scrollBarText(forContentOffset contentOffset: CGFloat, scrollIndicatorOffset: CGFloat) -> String? {
guard let section = sectionIndex(in: tableView, forOffset: contentOffset) else { return nil }
return shortHeaderDateFormatter.string(from: sections[section].date).capitalized
}
}

0 comments on commit 8168d6d

Please sign in to comment.