Slider label position
╱🚧 under construction
Last updated
Was this helpful?
╱🚧 under construction
Last updated
Was this helpful?
SwiftUI ⟩ controls ⟩ Slider ⟩ label position
設定 Slider
數值標籤到滑桿調鈕的上方。
方法 1:使用固定寬度的 Slider。
優點:容易計算數值標籤的偏移量。
缺點:寬度固定,適應性差。
.background
負責將子視圖 (Text
) 置中對齊 (Text
的中心點放在背景正中央)。
計算 Text
的偏移量時,須以中央為基準。
用 .offset()
偏移 Text
。
// view properties
@State var t: CGFloat = 0.5
let sliderWidth: CGFloat = 300 // ⭐️ 滑桿寬度
// view body
// slider(fixed width) > background > text
Slider(value: $t)
// ⭐️ 1. `.background` 負責將子視圖「置中對齊」
.background {
let knobWidth: CGFloat = 28 // 滑桿調鈕寬度
let knobRange = sliderWidth - knobWidth // 調鈕活動範圍寬度
// ⭐️ 2. 因為 Text 會被 .background 置中對齊
// 所以計算偏移量時,須以中央為基準。
let xOffset = (t-0.5) * knobRange // 數值標籤偏移量
// ⭐️ 3. 用 .offset() 偏移 Text。
Text("\(t, specifier: "%.2f")")
.offset(x: xOffset, y: -30)
}// background
.frame(width: sliderWidth) // ⭐️ 寬度固定
方法2-1:用 .background
+ GeometryReader
+ Slider
尺寸
優點:可適應母視圖的環境、大小可自動調整。
缺點:容易忽略 GeometryReader
以左上角為對齊方式,導致定位錯誤。
雖然 .background
負責將子視圖置中對齊,但 GeometryReader
是它唯一的子視圖,而且 GeometryReader
會用掉母視圖的所有空間,因此 .background
的對齊等於沒有效用❗️(對齊主要是由 GeometryReader
來接手掌控)
注意:GeometryReader
的對齊方式與 .background
不同,它負責將子視圖對齊「左上角」,因此在 GeometryReader
內部放置子視圖的邏輯與在 .background
內不同❗️
計算標籤偏移量時,要以 GeometryReader
左上角考量,同時還要考量到調鈕本身有一定寬度 (28),因此調鈕的中心點無法接觸到滑桿最左側,而是調鈕的左側接觸到滑桿的最左側,所以計算標籤偏移量時,還要先加上調鈕寬度的一半。
用 .position()
直接設定 Text 中心點位置。
優點:不會因為字體寬度變化而導致標籤位置與調鈕無法對齊。
// view's properties
@State var s: CGFloat = 100 // 滑桿數值
let range: ClosedRange<CGFloat> = 100...300 // 數值範圍
let min = range.lowerBound // 最小值
let max = range.upperBound // 最大值
// slider > background > GeometryReader > Text
Slider(value: $s, in: range)
// ⭐️ 1. 用 .background 搭配 GeometryReader 來取得 Slider 的尺寸
// - 雖然 background 負責將子視圖「置中對齊」,但 GeometryReader
// 是它唯一的子視圖,而且 GeometryReader 會用掉背景的所有空間,
// 因此 background 的對齊等於沒有效用❗️
.background {
// ⭐️ 2. 注意:GeometryReader 的對齊方式與 background 不同,
// 它負責將子視圖對齊「左上角」,因此在 GeometryReader 內部
// 放置子視圖的邏輯與在 background 內不同❗️
GeometryReader { geo in
let s2 = (s - min)/(max - min) // s2: 0...1
let knobWidth: CGFloat = 28 // 滑桿調鈕寬度
let sliderWidth = geo.size.width // 滑桿寬度
let knobRange = sliderWidth - knobWidth // 調鈕的活動寬度
// ⭐️ 3. 計算標籤偏移量時,要以 GeometryReader「左上角」考量,
// 同時還要考量到調鈕寬度。因為調鈕本身有一定寬度 (28),
// 因此調鈕的中心點無法接觸到滑桿的最左側,而是調鈕的「左側」
// 接觸到滑桿的最左側,所以計算標籤偏移量時,還要先加上調鈕寬度的一半。
let xOffset = (s2) * knobRange + knobWidth/2
// ⭐️ 4. 用 .position() 直接設定 Text 中心點位置
// 優點:不會因為字體寬度變化而導致標籤位置與調鈕無法對齊。
Text("\(s, specifier: "%.0f")")
.position(x: xOffset, y: -16)
// ❗️ 用 .offset() 的缺點:
// 計算 Text 偏移量時還要考慮到字體寬度變化,徒增計算困難。
// .offset(x: xOffset, y: -30)
}// GeometryReader
}// background
}// slider2-1
方法2-2:用 .background
+ GeometryReader
+ ZStack
幫忙置中
優點:
可適應母視圖、大小可自動調整。
ZStack
可幫忙置中子視圖。
缺點:
容易忘記將 ZStack
的尺寸放到與 GeometryReader
一樣大,導致 GeometryReader
依然先將整個 ZStack
放到左上角,然後 ZStack
才在它的內部做置中對齊,這時的置中對齊就是無效的,因為整個 ZStack
都在左上角❗️ (看上圖 slider 2-2)
忘記將 ZStack
的尺寸放到與 GeometryReader
一樣大❗️
GeometryReader
依然先將整個 ZStack
放到左上角,然後 ZStack
才在它的內部做置中對齊❗️
計算偏移量時,仍以「中心點」為考量,導致標籤位置錯誤❗️
// view's properties
@State var s: CGFloat = 100 // 滑桿數值
// view body
// 2-2. slider > overlay > GeometryReader > ZStack > Text
Slider(value: $s, in: 100...300)
.background {
// ⭐️ 2. GeometryReader 依然先將整個 ZStack 放到左上角,
// 然後 ZStack 才在它的內部做置中對齊❗️
GeometryReader { geo in
let s2 = (s - 100)/(300 - 100) // s2: 0...1
let width = geo.size.width - 28 // 調鈕的活動寬度
// ⭐️ 3. 計算偏移量時,仍以「中心點」為考量,導致標籤位置錯誤❗️
let xOffset: CGFloat = (s2 - 0.5) * width
// ⭐️ 1. 忘記將 ZStack 尺寸放到與 GeometryReader 一樣大
ZStack {
Text("\(s, specifier: "%.0f")")
.offset(x: xOffset, y: -30)
}// ZStack
}// GeometryReader
}// background
方法2-3:同方法2-2,但修正了忘記將 ZStack
的尺寸放到與 GeometryReader
一樣大的問題,如此 ZStack
才能真正接手幫忙置中對齊子視圖。
使用 .overlay
+ GeometryReader
取得 Slider
尺寸 (geo.size
)
先扣除 GeometryReader
左右各一半滑桿調鈕的寬度。
註:當一個 view 用到 view modifier 時,母子視圖關係要倒過來看,例如:child.parent().grandParent()
,前面的是子視圖、後面的是母視圖❗️
利用 .frame(maxWidth: .infinity, maxHeight: .infinity)
將 ZStack
的尺寸放到最大 (GeometryReader
扣掉調鈕寬度)
此時 ZStack
就能正常幫忙置中對齊 Text
。
利用 GeometryReader
的資訊 (geo.size
) 計算 ZStack
的尺寸。
(註:這裡有用到 GeometryKit 這個 custom package)
用 .position()
直接設定標籤中心點位置 (此時標籤會疊在調鈕正上方)。
用 .offset()
將標籤往上調。
// view's properties
let bounds: ClosedRange<CGFloat> = 100...300
let min = bounds.lowerBound
let max = bounds.upperBound
// view body
// 2-3. slider > overlay > GeometryReader > ZStack(maximized) > Text
Slider(value: $s, in: bounds)
.overlay {
// ⭐️ 1. overlay + GeometryReader 取得 Slider 尺寸 (geo.size)
GeometryReader { geo in
let s2 = (s - min)/(max - min) // s2: 0...1
let knobWidth: CGFloat = 28
let yOffset: CGFloat = -16
// ⭐️ 5. 計算 ZStack 的大小
let zstackSize = geo.size - CGSize(knobWidth, 0) // GeometryKit
// ⭐️ 4. ZStack 幫忙置中對齊 Text
ZStack(alignment: .center) {
Text("\(s, specifier: "%.0f")")
// ⭐️ 6. 用 .position() 直接設定標籤中心點位置,
// 此時標籤會疊在調鈕正上方。
.position(zstackSize[s2, 0]) // GeometryKit
// ⭐️ 7. 將標籤往上調。
.offset(y: yOffset)
}// ZStack
// ⭐️ 3. 將 ZStack 的尺寸放到最大
.frame(maxWidth: .infinity, maxHeight: .infinity)
// ⭐️ 2. 先扣除 GeometryReader 左右各一半滑桿調鈕的寬度
.padding(.horizontal, knobWidth/2) // knob half width
}//GeometryReader
.allowsHitTesting(false) // 防止 overlay 擋住 slider
}// overlay
方法2-4:同方法2-3。眼尖的人應該會察覺到,雖然方法2-3中我們有用 ZStack
來幫忙置中子視圖,它唯一的子視圖 Text
卻選擇了 .position()
來直接設定自己的中心點位置。換句話說,ZStack
根本沒起任何作用,因此可以直接移除 ZStack
❗️
🚧
import SwiftUI
import GeometryKit // CGSize as Vector2D
struct SliderValuePosition: View {
@State var t: CGFloat = 0.5
@State var s: CGFloat = 100
// slider range
let range: ClosedRange<CGFloat> = 100...300
var min: CGFloat { range.lowerBound }
var max: CGFloat { range.upperBound }
var s2: CGFloat { (s - min)/(max - min) } // s2: 0...1
let knobWidth: CGFloat = 28 // slider knob width
let yOffset: CGFloat = -16 // label y-offset
var body: some View {
ScrollView {
VStack(spacing: 100) {
Spacer()
slider1
HStack {
slider2_1
slider2_2
}
slider2_3
slider2_4
Spacer()
}// VSTack
.padding()
.frame(maxWidth: 900)
}// ScrollView
}// body
// 1. slider(fixed width) > background > text
var slider1: some View {
let sliderWidth: CGFloat = 300 // ⭐️ fixed slider width
let knobRange = sliderWidth - knobWidth
let xOffset = (t-0.5) * knobRange // -0.5...0.5 * knobRange
return Slider(value: $t).background {
Text("\(t, specifier: "%.2f")")
.offset(x: xOffset, y: -30)
}// slider.background
.bgColor(.green)
.frame(width: sliderWidth)
.borderAndTitle("slider 1 (⭐️ fixed width ⭐️)")
}// slider1
// 2-1. slider > background > GeometryReader > Text
var slider2_1: some View {
Slider(value: $s, in: range)
.background {
GeometryReader { geo in
let sliderWidth = geo.size.width // slider width
let knobRange = sliderWidth - knobWidth // knob 的活動寬度
let xOffset = s2 * knobRange + knobWidth/2 // label x-offset
// ⭐️ 用 .position() 的優點:
// 直接設定中心點位置,不會因為字體寬度變化而導致位置偏移
Text("\(s, specifier: "%.0f")")
.position(x: xOffset, y: -16)
// ❗️ 用 .offset() 的缺點:
// 在 GeometryReader 內部,子視圖會對齊左上角,
// 因此計算 Text 的偏移量時還要考慮到字體寬度變化,
// 導致計算上的困難。
// .offset(x: xOffset, y: -30)
}// GeometryReader
.bgColor(.yellow).allowsHitTesting(false)
}// background
.borderAndTitle("slider 2-1")
}// slider2-1
// 2-2. slider > background > GeometryReader > ZStack > Text
var slider2_2: some View {
Slider(value: $s, in: range)
.background {
GeometryReader { geo in
let sliderWidth = geo.size.width
let knobRangeWidth = sliderWidth - knobWidth
let xOffset = (s2 - 0.5) * knobRangeWidth
ZStack {
Text("\(s, specifier: "%.0f")")
.offset(x: xOffset, y: -30) // move upward
}// ZStack
.bgColor(.red, opacity: 0.9)
}// GeometryReader
.bgColor(.yellow)
.allowsHitTesting(false)
}// slider.background
.borderAndTitle("slider 2-2")
}// slider2_2
// 2-3. slider > overlay > GeometryReader > ZStack(maximized) > Text
var slider2_3: some View {
Slider(value: $s, in: range)
.overlay {
GeometryReader { geo in
let zstackSize = geo.size - CGSize(knobWidth, 0) // GeometryKit
ZStack {
Text("\(s, specifier: "%.0f")")
// ⭐️ position label directly
.position(zstackSize[s2, 0]) // GeometryKit
.offset(y: yOffset) // move upward
}// ZStack
// ⭐️ maximize ZStack
.frame(maxWidth: .infinity, maxHeight: .infinity)
.bgColor(.red)
.padding(.horizontal, knobWidth/2) // knob half width
}//GeometryReader
.allowsHitTesting(false)
}// slider.overlay
.borderAndTitle("slider 2-3")
}// slider2_3
// 2-4. slider > background > GeometryReader > Text
var slider2_4: some View {
Slider(value: $s, in: range)
.background {
GeometryReader { geo in
Text("\(s, specifier: "%.0f")")
// ⭐️ position label directly
.position(geo.size[s2, 0]) // GeometryKit
.offset(y: yOffset) // move upward
}//GeometryReader
.padding(.horizontal, knobWidth/2) // ⭐️ knob half width
.bgColor(.yellow)
.allowsHitTesting(false)
}// background
.borderAndTitle("slider 2-4")
}// slider2_4
}// SliderValuePosition
// view extension
private extension View {
/// `view.borderAndTitle("name")`
func borderAndTitle(_ title: String) -> some View {
self
.border(.secondary)
.padding()
.border(.secondary)
.overlay {
Text(title)
.offset(y: 50)
.foregroundStyle(Color.secondary)
.multilineTextAlignment(.center)
}
}
/// `view.bgColor(.red)`
func bgColor(_ color: Color, opacity: Double = 0.5) -> some View {
self.background(color.opacity(opacity))
}
}
GeometryReader:用於取得 Slider 的寬度。
GeometryKit:custom package