RedX
RedX

Reputation: 501

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()?

Upvotes: 14

Views: 11851

Answers (2)

pfurbacher
pfurbacher

Reputation: 1898

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" ]))


Upvotes: 2

krjw
krjw

Reputation: 4450

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.

Upvotes: 2

Related Questions