From 207def09b1c561095d7304c70f4706636cc7549d Mon Sep 17 00:00:00 2001 From: Yusuke Morishita Date: Tue, 7 Jul 2026 07:03:26 -0700 Subject: [PATCH] Add SwiftLint Add a .swiftlint.yml covering the package sources, tests, and the example app, and a SwiftLint CI job (pinned docker image, --strict so warnings fail). The config deviates from the defaults only where the codebase's structure is deliberate: option types nest by component, tests keep windows alive with withExtendedLifetime tuples, and the UIColor tests use conventional r/g/b/a component names. Fix the violations the defaults caught: - wrap the remaining 160+ character lines - rename single-letter locals (i, m) - drop redundant '= nil' optional initializations - remove the stale 2017 'case infinity' TODO - trailing whitespace/newline fixes --- .github/workflows/test.yml | 13 ++++++ .swiftlint.yml | 40 ++++++++++++++++ .../SwipeMenuView.swift | 6 ++- .../SwipeMenuViewController.swift | 5 +- .../SwipeMenuViewOptions.swift | 5 +- Sources/SwipeMenuViewController/TabView.swift | 46 +++++++++++++------ 6 files changed, 95 insertions(+), 20 deletions(-) create mode 100644 .swiftlint.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8de3ad..04d6ae2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,19 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: + lint: + name: SwiftLint + runs-on: ubuntu-latest + timeout-minutes: 10 + container: + # Pin the SwiftLint release so lint results are reproducible. + image: ghcr.io/realm/swiftlint:0.64.1 + steps: + - uses: actions/checkout@v7 + - name: Lint + # --strict promotes warnings to errors, so any violation fails the job. + run: swiftlint lint --strict --reporter github-actions-logging + package: name: Package tests (iOS Simulator) runs-on: macos-26 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..3440043 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,40 @@ +# SwiftLint configuration for SwipeMenuViewController. +# +# CI runs `swiftlint lint --strict`, so every warning fails the build. The +# thresholds below are deliberate deviations from the defaults, chosen to match +# how this codebase is organized rather than to silence real problems. + +included: + - Sources + - Tests + - Example/Example + - Example/ExampleTests + +# SwipeMenuViewOptions groups its option structs by component +# (TabView.IndicatorView.Underline, ...); the nesting is the API design. +nesting: + type_level: 3 + +# Allow idiomatic two-letter names such as `vc` in tests, and the +# conventional color-component names in the UIColor tests. +identifier_name: + min_length: 2 + excluded: + - r + - g + - b + - a + +line_length: + warning: 160 + ignores_urls: true + +file_length: + warning: 600 + +type_body_length: + warning: 350 + +# Tests keep windows/data sources alive with withExtendedLifetime((a, b, c)). +large_tuple: + warning: 4 diff --git a/Sources/SwipeMenuViewController/SwipeMenuView.swift b/Sources/SwipeMenuViewController/SwipeMenuView.swift index e0d0847..0f7d305 100644 --- a/Sources/SwipeMenuViewController/SwipeMenuView.swift +++ b/Sources/SwipeMenuViewController/SwipeMenuView.swift @@ -299,7 +299,11 @@ open class SwipeMenuView: UIView { tabView = TabView(frame: CGRect(x: 0, y: 0, width: frame.width, height: options.tabView.height), options: options.tabView) tabView?.clipsToBounds = options.tabView.clipsToBounds - contentScrollView = ContentScrollView(frame: CGRect(x: 0, y: options.tabView.height, width: frame.width, height: frame.height - options.tabView.height), default: defaultIndex, options: options.contentScrollView) + let contentFrame = CGRect(x: 0, + y: options.tabView.height, + width: frame.width, + height: frame.height - options.tabView.height) + contentScrollView = ContentScrollView(frame: contentFrame, default: defaultIndex, options: options.contentScrollView) contentScrollView?.clipsToBounds = options.contentScrollView.clipsToBounds tabView?.update(defaultIndex) diff --git a/Sources/SwipeMenuViewController/SwipeMenuViewController.swift b/Sources/SwipeMenuViewController/SwipeMenuViewController.swift index de42f1f..498972b 100644 --- a/Sources/SwipeMenuViewController/SwipeMenuViewController.swift +++ b/Sources/SwipeMenuViewController/SwipeMenuViewController.swift @@ -87,7 +87,10 @@ open class SwipeMenuViewController: UIViewController, SwipeMenuViewDelegate, Swi /// than crashing. Override to provide pages that are not backed by `children`. open func swipeMenuView(_ swipeMenuView: SwipeMenuView, viewControllerForPageAt index: Int) -> UIViewController { guard children.indices.contains(index) else { - assertionFailure("SwipeMenuViewController: requested a page at \(index) but only \(children.count) child view controllers exist. Override the data source to provide the missing pages.") + assertionFailure(""" + SwipeMenuViewController: requested a page at \(index) but only \(children.count) \ + child view controllers exist. Override the data source to provide the missing pages. + """) // Return a detached placeholder rather than crashing. It is // intentionally not added via `addChild(_:)`: the default // `numberOfPages(in:)` counts `children`, so adding children on this diff --git a/Sources/SwipeMenuViewController/SwipeMenuViewOptions.swift b/Sources/SwipeMenuViewController/SwipeMenuViewOptions.swift index 8d73a68..9bbb4b8 100644 --- a/Sources/SwipeMenuViewController/SwipeMenuViewOptions.swift +++ b/Sources/SwipeMenuViewController/SwipeMenuViewOptions.swift @@ -8,7 +8,6 @@ public nonisolated struct SwipeMenuViewOptions: Sendable { public nonisolated enum Style: Sendable { case flexible case segmented - // TODO: case infinity } public nonisolated enum Indicator: Sendable { @@ -65,11 +64,11 @@ public nonisolated struct SwipeMenuViewOptions: Sendable { public nonisolated struct Circle: Sendable { /// The corner radius of the highlight when the indicator is `.circle`. /// Defaults to `nil`, which uses half the highlight's height (a capsule). - public var cornerRadius: CGFloat? = nil + public var cornerRadius: CGFloat? /// The corners rounded by `cornerRadius` when the indicator is `.circle`. /// Defaults to `nil`, which rounds all four corners. - public var maskedCorners: CACornerMask? = nil + public var maskedCorners: CACornerMask? } /// The padding around the indicator view. Defaults to `.zero`. diff --git a/Sources/SwipeMenuViewController/TabView.swift b/Sources/SwipeMenuViewController/TabView.swift index 2b0295f..98e83b6 100644 --- a/Sources/SwipeMenuViewController/TabView.swift +++ b/Sources/SwipeMenuViewController/TabView.swift @@ -299,11 +299,12 @@ open class TabView: UIScrollView { containerView.frame.size.width = containerWidth containerView.translatesAutoresizingMaskIntoConstraints = false - + let heightConstraint: NSLayoutConstraint switch options.indicator { case .underline: - heightConstraint = containerView.heightAnchor.constraint(equalToConstant: options.height - options.indicatorView.underline.height - options.indicatorView.padding.bottom) + let height = options.height - options.indicatorView.underline.height - options.indicatorView.padding.bottom + heightConstraint = containerView.heightAnchor.constraint(equalToConstant: height) case .circle, .none: heightConstraint = containerView.heightAnchor.constraint(equalToConstant: options.height) } @@ -334,8 +335,8 @@ open class TabView: UIScrollView { } private func updateSelectedItem(by newIndex: Int) { - for (i, itemView) in itemViews.enumerated() { - itemView.isSelected = i == newIndex + for (index, itemView) in itemViews.enumerated() { + itemView.isSelected = index == newIndex } } } @@ -358,20 +359,27 @@ extension TabView { switch options.indicator { case .underline: let itemView = itemViews[currentIndex] - indicatorView = UIView(frame: CGRect(x: itemView.frame.origin.x + options.indicatorView.padding.left, y: itemView.frame.height - options.indicatorView.padding.vertical, width: itemView.frame.width - options.indicatorView.padding.horizontal, height: options.indicatorView.underline.height)) + let padding = options.indicatorView.padding + indicatorView = UIView(frame: CGRect(x: itemView.frame.origin.x + padding.left, + y: itemView.frame.height - padding.vertical, + width: itemView.frame.width - padding.horizontal, + height: options.indicatorView.underline.height)) indicatorView.layer.cornerRadius = options.indicatorView.underline.cornerRadius indicatorView.backgroundColor = options.indicatorView.backgroundColor containerView.addSubview(indicatorView) case .circle: let itemView = itemViews[currentIndex] - let height = itemView.bounds.height - options.indicatorView.padding.vertical - indicatorView = UIView(frame: CGRect(x: itemView.frame.origin.x + options.indicatorView.padding.left, y: 0, width: itemView.frame.width - options.indicatorView.padding.horizontal, height: height)) + let padding = options.indicatorView.padding + indicatorView = UIView(frame: CGRect(x: itemView.frame.origin.x + padding.left, + y: 0, + width: itemView.frame.width - padding.horizontal, + height: itemView.bounds.height - padding.vertical)) indicatorView.layer.position.y = itemView.layer.position.y indicatorView.layer.cornerRadius = options.indicatorView.circle.cornerRadius ?? indicatorView.frame.height / 2 indicatorView.backgroundColor = options.indicatorView.backgroundColor - - if let m = options.indicatorView.circle.maskedCorners { - indicatorView.layer.maskedCorners = m + + if let maskedCorners = options.indicatorView.circle.maskedCorners { + indicatorView.layer.maskedCorners = maskedCorners } containerView.addSubview(indicatorView) @@ -432,11 +440,16 @@ extension TabView { guard let currentItem else { return } if options.indicatorView.isAnimationOnSwipeEnabled { + let padding = options.indicatorView.padding switch direction { case .forward: if let nextItem { - indicatorView.frame.origin.x = currentItem.frame.origin.x + (nextItem.frame.origin.x - currentItem.frame.origin.x) * ratio + options.indicatorView.padding.left - indicatorView.frame.size.width = currentItem.frame.size.width + (nextItem.frame.size.width - currentItem.frame.size.width) * ratio - options.indicatorView.padding.horizontal + indicatorView.frame.origin.x = currentItem.frame.origin.x + + (nextItem.frame.origin.x - currentItem.frame.origin.x) * ratio + + padding.left + indicatorView.frame.size.width = currentItem.frame.size.width + + (nextItem.frame.size.width - currentItem.frame.size.width) * ratio + - padding.horizontal if options.interpolatesTextColorOnSwipe { nextItem.titleLabel.textColor = options.itemView.textColor.convert(to: options.itemView.selectedTextColor, multiplier: ratio) currentItem.titleLabel.textColor = options.itemView.selectedTextColor.convert(to: options.itemView.textColor, multiplier: ratio) @@ -444,8 +457,12 @@ extension TabView { } case .reverse: if let previousItem { - indicatorView.frame.origin.x = previousItem.frame.origin.x + (currentItem.frame.origin.x - previousItem.frame.origin.x) * ratio + options.indicatorView.padding.left - indicatorView.frame.size.width = previousItem.frame.size.width + (currentItem.frame.size.width - previousItem.frame.size.width) * ratio - options.indicatorView.padding.horizontal + indicatorView.frame.origin.x = previousItem.frame.origin.x + + (currentItem.frame.origin.x - previousItem.frame.origin.x) * ratio + + padding.left + indicatorView.frame.size.width = previousItem.frame.size.width + + (currentItem.frame.size.width - previousItem.frame.size.width) * ratio + - padding.horizontal if options.interpolatesTextColorOnSwipe { previousItem.titleLabel.textColor = options.itemView.selectedTextColor.convert(to: options.itemView.textColor, multiplier: ratio) currentItem.titleLabel.textColor = options.itemView.textColor.convert(to: options.itemView.selectedTextColor, multiplier: ratio) @@ -530,4 +547,3 @@ extension TabView { } } } -