SwiftUI's AnimatableModifier is now deprecated, but how to "use Animatable directly"? - ios

I am aiming to achieve a callback when an animation of offset change is finished. So, I found a workaround online which uses the AnimatableModifier to check when the animatableData equals the target value.
struct OffsetAnimation: AnimatableModifier{
typealias T = CGFloat
var animatableData: T{
get { value }
set {
value = newValue
print("animating \(value)")
if watchForCompletion && value == targetValue {
DispatchQueue.main.async { [self] in onCompletion() }
}
}
}
var watchForCompletion: Bool
var value: T
var targetValue: T
init(value: T, watchForCompletion: Bool, onCompletion: #escaping()->()){
self.targetValue = value
self.value = value
self.watchForCompletion = watchForCompletion
self.onCompletion = onCompletion
}
var onCompletion: () -> ()
func body(content: Content) -> some View {
return content.offset(x: 0, y: value).animation(nil)
}
}
struct DemoView: View {
#State var offsetY: CGFloat = .zero
var body: some View {
Rectangle().frame(width: 100, height: 100, alignment: .center)
.modifier(
OffsetAnimation(value: offsetY,
watchForCompletion: true,
onCompletion: {print("translation complete")}))
.onAppear{
withAnimation{ offsetY = 100 }
}
}
}
But it turns out AnimatableModifier is now deprecated. And I cannot find an alternative to it.
I am aware that GeometryEffect will work for this case of offset change, where you could use ProjectionTransform to do the trick. But I am more concerned about the official recommendation to "use Animatable directly".
Seriously, the tutorials about the Animatable protocol I can find online all use examples of the Shape struct which implicitly implements the Animatable protocol. And the following code I improvised with the Animatable protocol doesn't even do the "animating".
struct RectView: View, Animatable{
typealias T = CGFloat
var animatableData: T{
get { value }
set {
value = newValue
print("animating \(value)")
}
}
var value: T
var body: some View{
Rectangle().frame(width: 100, height: 100, alignment: .center)
.offset(y:value).animation(nil)
}
}
struct DemoView: View{
#State var offsetY: CGFloat = .zero
var body: some View {
RectView(value: offsetY)
.onAppear{
withAnimation{ offsetY = 100 }
}
}
}
Thanks for your kind reading, and maybe oncoming answers!

Please use struct OffsetAnimation: Animatable, ViewModifier instead of struct OffsetAnimation: AnimatableModifier directly.

Try this (working, Xcode 13.4.1, iOS 15.5, inspired by https://www.avanderlee.com/swiftui/withanimation-completion-callback):
struct OffsetAnimation: ViewModifier, Animatable {
typealias T = CGFloat
var animatableData: T {
didSet {
animatableDataSetAction()
}
}
private func animatableDataSetAction() {
guard animatableData == targetValue else { return }
DispatchQueue.main.async {
self.onCompletion()
}
}
var targetValue: T
init(value: T, onCompletion: #escaping()->()){
self.targetValue = value
self.animatableData = value
self.onCompletion = onCompletion
}
var onCompletion: () -> ()
func body(content: Content) -> some View {
return content.offset(x: 0, y: targetValue)
}
}
struct DemoView: View {
#State var offsetY: CGFloat = .zero
#State private var myText = "Animating"
var body: some View {
VStack {
Text(myText).padding()
Rectangle().frame(width: 100, height: 100, alignment: .center)
.modifier(
OffsetAnimation(value: offsetY,
onCompletion: {myText = "Translation finished"}))
.onAppear{
withAnimation(.easeIn(duration: 3.0)){
offsetY = 100
}
}
}
}
}

Related

Tap anywhere to add multiple items on canvas

import SwiftUI
struct Level1: View {
#State var tapScore = 0
#State var showingMinedHammer = false
#State var points:[CGPoint] = [CGPoint(x:0,y:0), CGPoint(x:50,y:50)]
#State private var location = CGPoint.zero // < here !!
func mine() {
tapScore += 1
showMinedHammer()
}
func showMinedHammer() {
self.showingMinedHammer = true
DispatchQueue.main.asyncAfter(deadline: .now() + 99) {
self.showingMinedHammer = false
}
}
var body: some View {
GeometryReader { geometryProxy in
ZStack {
Image("hammer.fill").resizable().frame(width: UIScreen.main.bounds.height * 1.4, height: UIScreen.main.bounds.height)
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
self.location = value.location // < here !!
self.mine()
})
if self.showingMinedHammer {
Image(systemName: "hammer.fill")
.resizable()
.frame(width: 30, height: 30)
.position(self.location) // < here !!
}
}
}.edgesIgnoringSafeArea(.all)
}
}
struct Level1_Previews: PreviewProvider {
static var previews: some View {
Level1()
}
}
struct GetTapLocation:UIViewRepresentable {
var tappedCallback: ((CGPoint) -> Void)
func makeUIView(context: UIViewRepresentableContext<GetTapLocation>) -> UIView {
let v = UIView(frame: .zero)
let gesture = UITapGestureRecognizer(target: context.coordinator,
action: #selector(Coordinator.tapped))
v.addGestureRecognizer(gesture)
return v
}
class Coordinator: NSObject {
var tappedCallback: ((CGPoint) -> Void)
init(tappedCallback: #escaping ((CGPoint) -> Void)) {
self.tappedCallback = tappedCallback
}
#objc func tapped(gesture:UITapGestureRecognizer) {
let point = gesture.location(in: gesture.view)
self.tappedCallback(point)
}
}
func makeCoordinator() -> GetTapLocation.Coordinator {
return Coordinator(tappedCallback:self.tappedCallback)
}
func updateUIView(_ uiView: UIView,
context: UIViewRepresentableContext<GetTapLocation>) {
}
}
New to SwiftUI and I am trying to combine gestures that allow me to tap anywhere on the screen to add an infinite amount of "Images", but currently the image only stays on screen for a short while. Where am I going wrong? Am I supposed to combine another gesture to get the item to stay on screen whilst adding?
You only have a singular location, so only one item will ever appear in your example. To have multiple items, you'll need some sort of collection, like an Array.
The following is a pared-down example showing using an array:
struct Hammer: Identifiable {
var id = UUID()
var location: CGPoint
}
struct Level1: View {
#State var hammers: [Hammer] = [] //<-- Start with `none`
var body: some View {
ZStack {
ForEach(hammers) { hammer in // Display all of the hammers
Image(systemName: "hammer.fill")
.resizable()
.frame(width: 30, height: 30)
.position(hammer.location)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
self.hammers.append(Hammer(location: value.location)) // Add a Hammer
})
.edgesIgnoringSafeArea(.all)
}
}
Note: I'm unclear on what the GeometryReader is for in your code -- you declare it, then use UIScreen dimensions -- normally in SwiftUI we just use GeometryReader

SwiftUI. Subview animation is not working if subview's View #State was changed while parent View is animating. iOS 16

I have a structure, where the parent view is a hidden pop-up with content. On some user action parent View starts to slide up. I need all its content to slide up with the parent View. It was working perfectly before iOS 16, but now it's broken. If the child's View subview #State is changed during the animation, then this View appears instantly on a screen, i.e. not sliding. As I understand, because View's #State was changed SwiftUI redraws this particular View and disables its animation, so it appears in its final position without animation. I was trying to force the animation of this particular View using .animation or withAnimation. Nothing of it helped. How to fix this bug in iOS 16?
Minimal reproducible example:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
import SwiftUI
struct ContentView: View {
#State var shouldShow: SlideCardPosition = .bottom
#State var text1: String = ""
var body: some View {
VStack {
Button {
shouldShow = .top
} label: {
Text("Show PopUp")
.padding(.top, 100)
}
PopUpUIView(shouldShow: $shouldShow)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import SwiftUI
struct SlideOverCard<Content: View>: View {
#GestureState private var dragState = SlideDragState.inactive
#Binding private var position: SlideCardPosition
#State private var highlightedBackground = false
private var contentHeight: CGFloat
private var backgroundColor: Color?
private var withCorners: Bool
private var isHandleHidden: Bool
private var overlayOpacity: CGFloat
init(position: Binding<SlideCardPosition>,
contentHeight: CGFloat,
backgroundColor: Color? = nil,
withCorners: Bool = true,
isHandleHidden: Bool = false,
overlayOpacity: CGFloat = 0.75,
content: #escaping () -> Content) {
_position = position
self.content = content
self.contentHeight = contentHeight
self.backgroundColor = backgroundColor
self.withCorners = withCorners
self.isHandleHidden = isHandleHidden
self.overlayOpacity = overlayOpacity
}
var content: () -> Content
var body: some View {
return Rectangle()
.frame(width: UIScreen.screenWidth, height: UIScreen.screenHeight)
.foregroundColor(Color.black.opacity(highlightedBackground ? overlayOpacity : 0))
.position(x: UIScreen.screenWidth / 2, y: (UIScreen.screenHeight) / 2)
.edgesIgnoringSafeArea([.top, .bottom])
.overlay(
Group {
VStack(spacing: 0) {
if !isHandleHidden {
Handle()
}
self.content()
Spacer()
}
}
.frame(width: UIScreen.screenWidth, height: UIScreen.screenHeight)
.background(backgroundColor != nil ? backgroundColor! : Color.black)
.cornerRadius(withCorners ? 40.0 : 0)
.shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
.offset(y: position(from: position) + dragState.translation.height)
.animation(dragState.isDragging ? nil : .interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0), value: UUID())
.edgesIgnoringSafeArea([.top, .bottom])
.onTapGesture {}
.onChange(of: position) { _ in
withAnimation(.easeInOut) {
highlightedBackground.toggle()
}
}
)
.onTapGesture {
position = position == .bottom ? .top : .bottom
}
}
private func position(from cardPosition: SlideCardPosition) -> CGFloat {
switch cardPosition {
case .top: return UIScreen.screenHeight - contentHeight - UIScreen.topSafeAreaHeight
case .bottom: return 1000
}
}
}
enum SlideCardPosition {
case top
case bottom
}
private enum SlideDragState {
case inactive
case dragging(translation: CGSize)
var translation: CGSize {
switch self {
case .inactive:
return .zero
case .dragging(let translation):
return translation
}
}
var isDragging: Bool {
switch self {
case .inactive:
return false
case .dragging:
return true
}
}
}
private struct Handle: View {
private let handleThickness: CGFloat = 5
var body: some View {
RoundedRectangle(cornerRadius: handleThickness / 2.0)
.frame(width: 34, height: handleThickness)
.foregroundColor(.white)
.padding(.top, 8)
}
}
import UIKit
extension UIScreen {
static let screenWidth = UIScreen.main.bounds.size.width
static let screenHeight = UIScreen.main.bounds.size.height
static let screenSize = UIScreen.main.bounds.size
private static let window = UIApplication.shared.windows[0]
private static let safeFrame = window.safeAreaLayoutGuide.layoutFrame
static var topSafeAreaHeight: CGFloat {
safeFrame.minY
}
static var bottomSafeAreaHeight: CGFloat {
window.frame.maxY - safeFrame.maxY
}
}
import SwiftUI
struct PopUpUIView: View {
#Binding var shouldShow: SlideCardPosition
#State var text1 = "some random text"
var body: some View {
SlideOverCard(position: $shouldShow,
contentHeight: 300) {
VStack(spacing: 10) {
Text(text1)
.foregroundColor(.white)
.padding(.top, 80)
}
}.onChange(of: shouldShow) { _ in
if shouldShow == .top {
text1 = UUID().uuidString
}
}
}
}
struct PopUpUIView_Previews: PreviewProvider {
static var previews: some View {
PopUpUIView(shouldShow: .constant(.bottom))
}
}
Example of incorrect animation with a dynamic text.
Example of what I want to achieve. It is working fine if text is static.

.onChange(of: ) for multiple #State properties at once?

Is there a way I can use .onChange to detect the change of multiple #State properties at once? I know I could just chain 2 .onChange modifiers but it would be better if I could just detect all at once and run some code.
#State private var width = 0.0
#State private var height = 0.0
var body: some View {
Button(action: {
width += 0.1
}, label: {
Text("Width + 0.1")
})
.onChange(of: width) { _ in
print("Changed")
}
}
For this case here is the simplest I think
.onChange(of: width + height) { _ in
print("Changed")
}
Update: as I wrote above is 'simplest' (and for specific scenarios can be enough), but of course other variants, "more smart/heavy/generic/etc", are also available.
Thanks to #AgentBilly, here is one of them:
.onChange(of: [width, height]) { _ in
print("Changed")
}
Create a struct and use that as your state:
struct Size {
var width: Double = 0
var height: Double = 0
// mutating func exampleMethod(){
// }
}
Then init it as your state:
#State var size = Size()
var body: some View {
Button(action: {
size.width += 0.1
}) {
Text("Width + 0.1")
}
.onChange(of: size) { newSize in
print("Changed")
}
}

How to pass and access property from one view to another in SwiftUI?

I have one view which I am using in another view, and i want to access property from one view to another...but i am not sure how to do it.. (My main problem with below code - I really appreciate if I got solution for this - is I am having dropdown even when it is collapsed its expanding when i clicked outside of it..which is wrong)
import SwiftUI
public struct TopSheet<Content>: View where Content : View {
private var content: () -> Content
let minHeight: CGFloat = 50.0
let startHeight: CGFloat = 50.0
let maxOpacity: CGFloat = 0.8
let maxArrowOffset: CGFloat = 8.0
let minimumContentHeight = 61.0
#State private var currentHeight: CGFloat = 0
#State private var contentHeight: CGFloat = 0
#State private var backgroundColor: Color = Color.clear
#State private var expand = false
#State private var arrowOffset: Double = 0
public init(#ViewBuilder content: #escaping () -> Content) { self.content = content }
public func expandRatio() -> Double { return max((currentHeight - minHeight) / contentHeight, 0) }
private func isTopSheetExpandable() -> Bool {
return contentHeight > minimumContentHeight
}
public var body: some View {
let tap = TapGesture()
.onEnded { _ in
expand.toggle()
if expand {
withAnimation(Animation.easeOut) {
currentHeight = max(contentHeight, minHeight)
self.backgroundColor = Color.grey
}
}
else {
withAnimation(Animation.easeOut) {
currentHeight = minHeight
self.backgroundColor = Color.clear
}
}
self.arrowOffset = expandRatio() * maxArrowOffset
}
let drag = DragGesture()
.onChanged { value in
currentHeight += value.translation.height
currentHeight = max(currentHeight, minHeight)
let opacity = min(expandRatio() * maxOpacity, maxOpacity)
self.backgroundColor = Color.gray.opacity(opacity)
self.arrowOffset = expandRatio() * maxArrowOffset
}
.onEnded { value in
expand.toggle()
if expand {
withAnimation(Animation.easeOut) {
currentHeight = max(contentHeight, minHeight)
self.backgroundColor = Color.gray.opacity(maxOpacity)
}
}
else {
withAnimation(Animation.easeOut) {
currentHeight = minHeight
self.backgroundColor = Color.clear
}
}
self.arrowOffset = expandRatio() * maxArrowOffset
}
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
VStack(spacing: 0) {
GeometryReader { geo in
content()
.viewHeight()
.fixedSize(horizontal: false, vertical: true)
}.onPreferenceChange(ViewHeightPreferenceKey.self) { height in
contentHeight = height
currentHeight = startHeight
}.clipped()
Spacer(minLength: 0)
}
.frame(height: currentHeight)
HStack(alignment: .center) {
Spacer()
if isTopSheetExpandable() {
Arrow(offset: arrowOffset)
.stroke(Color.gray, style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round))
.frame(width: 30, height: 4)
.padding(.bottom, 10)
.padding(.top, 10)
}
Spacer()
}
// .contentShape(Rectangle())
.gesture(drag)
.gesture(tap)
.animation(.easeInOut, value: 2)
}
.background(Color.white)
Spacer()
}
.background(self.backgroundColor.edgesIgnoringSafeArea([.vertical, .horizontal, .leading, .trailing, .top, .bottom]))
.simultaneousGesture(isTopSheetExpandable() ? tap : nil)
}
}
fileprivate struct Arrow: Shape {
var offset: Double
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: .zero)
path.addLine(to: CGPoint(x: rect.width/2, y: -offset))
path.move(to: CGPoint(x: rect.width/2, y: -offset))
path.addLine(to: CGPoint(x: rect.width, y: 0))
return path
}
}
Another view
import SwiftUI
struct NextView: View {
#State private var backgroundColor: Color = Color.gray
#State private var users: [String] = ["abc", "xyz", "pqr", "mno", "pqr", "ert", ""]
var accessibilityID: String
var body: some View {
ZStack {
Rectangle()
.fill()
.foregroundColor(backgroundColor)
TopSheet {
VStack(spacing: 0) {
ForEach($users, id: \.self) { user in
HStack {
Text(user.wrappedValue)
.padding(.vertical, 10)
Spacer()
}
.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded {
self.users = [user.wrappedValue] + self.users.filter { $0 != user.wrappedValue }
switch self.users.first.unsafelyUnwrapped {
case "Joe Black": self.backgroundColor = .black
case "Eva Green": self.backgroundColor = .green
case "Jared Leto": self.backgroundColor = .red
default: self.backgroundColor = .gray
}
})
}
}
.padding(.horizontal, 10)
}
}
}
}
I might try some trick if I can able to access "expand" property from "TopSheet" and access it in "NextView"
You should store expand as #State in your parent View and pass it via Binding. Simplified example:
public struct TopSheet<Content>: View where Content : View {
#Binding var expand : Bool
var content: () -> Content
//declare other properties private so they don't get used in the generated init
public var body: some View {
Text("Here")
}
}
struct NextView: View {
#State private var expand = false
var body: some View {
TopSheet(expand: $expand) {
Text("Content")
}
}
}
If you really want/need your explicit init, you can do this:
public struct TopSheet<Content>: View where Content : View {
#Binding private var expand : Bool
private var content: () -> Content
public init(expand: Binding<Bool>, #ViewBuilder content: #escaping () -> Content) {
self._expand = expand
self.content = content
}
public var body: some View {
Text("Here")
}
}

SwiftUI Custom PickerStyle

I'm trying to write a custom PickerStyle that looks similar to the SegmentedPickerStyle(). This is my current status:
import SwiftUI
public struct FilterPickerStyle: PickerStyle {
public static func _makeView<SelectionValue>(value: _GraphValue<_PickerValue<FilterPickerStyle, SelectionValue>>, inputs: _ViewInputs) -> _ViewOutputs where SelectionValue : Hashable {
}
public static func _makeViewList<SelectionValue>(value: _GraphValue<_PickerValue<FilterPickerStyle, SelectionValue>>, inputs: _ViewListInputs) -> _ViewListOutputs where SelectionValue : Hashable {
}
}
I created a struct that conforms to the PickerStyle protocol. Xcode then added the required protocol methods, but I don't know how to use them. Could someone explain how to deal with these methods, if I for example want to achieve something similar to the SegmentedPickerStyle()?
I haven't finished it yet since other stuff came up, but here is my (unfinished attempt to implement a SegmentedPicker):
struct SegmentedPickerElementView<Content>: View where Content : View {
#Binding var selectedElement: Int
let content: () -> Content
#inlinable init(_ selectedElement: Binding<Int>, #ViewBuilder content: #escaping () -> Content) {
self._selectedElement = selectedElement
self.content = content
}
var body: some View {
GeometryReader { proxy in
self.content()
.fixedSize(horizontal: true, vertical: true)
.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
.contentShape(Rectangle())
}
}
}
struct SegmentedPickerView: View {
#Environment (\.colorScheme) var colorScheme: ColorScheme
var elements: [(id: Int, view: AnyView)]
#Binding var selectedElement: Int
#State var internalSelectedElement: Int = 0
private var width: CGFloat = 620
private var height: CGFloat = 200
private var cornerRadius: CGFloat = 20
private var factor: CGFloat = 0.95
private var color = Color(UIColor.systemGray)
private var selectedColor = Color(UIColor.systemGray2)
init(_ selectedElement: Binding<Int>) {
self._selectedElement = selectedElement
self.elements = [
(id: 0, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("4").font(.system(.title))
})),
(id: 1, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("5").font(.system(.title))
})),
(id: 2, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("9").font(.system(.title))
})),
(id: 3, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("13").font(.system(.title))
})),
(id: 4, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("13").font(.system(.title))
})),
(id: 5, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("13").font(.system(.title))
})),
]
self.internalSelectedElement = selectedElement.wrappedValue
}
func calcXPosition() -> CGFloat {
var pos = CGFloat(-self.width * self.factor / 2.4)
pos += CGFloat(self.internalSelectedElement) * self.width * self.factor / CGFloat(self.elements.count)
return pos
}
var body: some View {
ZStack {
Rectangle()
.foregroundColor(self.selectedColor)
.cornerRadius(self.cornerRadius * self.factor)
.frame(width: self.width * self.factor / CGFloat(self.elements.count), height: self.height - self.width * (1 - self.factor))
.offset(x: calcXPosition())
.animation(.easeInOut(duration: 0.2))
HStack(alignment: .center, spacing: 0) {
ForEach(self.elements, id: \.id) { item in
item.view
.gesture(TapGesture().onEnded { _ in
print(item.id)
self.selectedElement = item.id
withAnimation {
self.internalSelectedElement = item.id
}
})
}
}
}
.frame(width: self.width, height: self.height)
.background(self.color)
.cornerRadius(self.cornerRadius)
.padding()
}
}
struct SegmentedPickerView_Previews: PreviewProvider {
static var previews: some View {
SegmentedPickerView(.constant(1))
}
}
I haven't figured out the formula where the value 2.4 sits... it depends on the number of elements... her is what I have learned:
2 Elements = 4
3 Elements = 3
4 Elements = 2.6666
5 Elements = ca. 2.4
If you figure that out and fix the alignment of the content in the pickers its basically fully adjustable ... you could also pass the width and height of the hole thing ore use GeometryReader
Good Luck!
P.S.: I will update this when its finished but at the moment it is not my number one priority so don't expect me to do so.
The following code simplifies the design of the SegmentPickerElementView and the maintenance of selection state. Also, it fixes the selection indicator’s size (width & height) calculation in the original posting. Note that the indicator in this solution is in the foreground, effectively “sliding” across the surface of the HStack of choices (segments). Finally, this was developed on an iPad, using Swift Playgrounds. If you are using XCode on a Mac, you would want to comment out the PlaygroundSupport code, and uncomment the SegmentedPickerView_Previews struct code.
Code updated for iOS 15
import Foundation
import Combine
import SwiftUI
import PlaygroundSupport
struct SegmentedPickerElementView<Content>: Identifiable, View where Content : View {
var id: Int
let content: () -> Content
#inlinable init(id: Int, #ViewBuilder content: #escaping () -> Content) {
self.id = id
self.content = content
}
var body: some View {
/*
By simply wrapping “content” in a GeometryReader
you get a view which will flexibly take up the available
width in the parent container. As "Hacking Swift" put it:
"GeometryReader has an interesting side effect that might
catch you out at first: the view that gets returned has a
flexible preferred size, which means it will expand to
take up more space as needed."
(https://www.hackingwithswift.com/books/ios-swiftui/understanding-frames-and-coordinates-inside-geometryreader)
Interesting side effect, indeed. (Don't know about you,
but I don't like side effects, interesting or not.) As
suggested in the cited article, uncomment the
“background()“ modifiers to see this side effect.
*/
GeometryReader { proxy in
self.content()
// Sizing seems to have changed in iOS 14 or 15
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
}
}
}
struct SegmentedPickerView: View {
#Environment (\.colorScheme) var colorScheme: ColorScheme
#State var selectedIndex: Int = 0
#State var elementWidth: CGFloat = 0
// The values for width and height are arbitrary, and this part
// of the implementation can be improved (left to the reader).
private let width: CGFloat = 380
private let height: CGFloat = 72
private let cornerRadius: CGFloat = 8
private let selectorStrokeWidth: CGFloat = 4
private let selectorInset: CGFloat = 6
private let backgroundColor = Color(UIColor.lightGray)
private let choices: [String]
private var elements: [SegmentedPickerElementView<Text>] = [SegmentedPickerElementView<Text>]()
init(choices: [String]) {
self.choices = choices
for i in choices.indices {
self.elements.append(SegmentedPickerElementView(id: i) {
Text(choices[i]).font(.system(.title))
})
}
self.selectedIndex = 0
}
#State var selectionOffset: CGFloat = 0
func updateSelectionOffset(id: Int) {
let widthOfElement = self.width/CGFloat(self.elements.count)
self.selectedIndex = id
selectionOffset = CGFloat((widthOfElement * CGFloat(id)) + widthOfElement/2.0)
}
var body: some View {
VStack {
ZStack(alignment: .leading) {
HStack(alignment: .center, spacing: 0) {
ForEach(self.elements) { item in
(item as SegmentedPickerElementView )
.onTapGesture(perform: {
withAnimation {
self.updateSelectionOffset(id: item.id)
}
})
}
}
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(Color.gray, lineWidth: selectorStrokeWidth)
.foregroundColor(Color.clear)
// add color highlighting (optional)
.background(.yellow.opacity(0.25))
.frame(
width: (width/CGFloat(elements.count)) - 2.0 * selectorInset,
height: height - 2.0 * selectorInset)
.position(x: selectionOffset, y: height/2.0)
.animation(.easeInOut(duration: 0.2))
}
.frame(width: width, height: height)
.background(backgroundColor)
.cornerRadius(cornerRadius)
.padding()
Text("selected element: \(selectedIndex) -> \(choices[selectedIndex])")
}.onAppear(perform: { self.updateSelectionOffset(id: 0) })
}
}
// struct SegmentedPickerView_Previews: PreviewProvider {
// static var previews: some View {
// SegmentedPickerView(choices: ["A", "B", "C", "D", "E", "F" ])
// }
// }
PlaygroundPage.current.setLiveView(SegmentedPickerView(choices: ["A", "B", "C", "D", "E", "F" ]))

Resources