Para fazer um controle de arraste personalizado em SwiftUI ajustar-se a valores discretos, converta a posição local do ponteiro em uma fração limitada, arredonde-a para um índice inteiro e desenhe o botão deslizante a partir desse índice. Este exemplo para macOS seleciona um entre cinco tamanhos de círculo e confirma a seleção ao soltar.

Escolha a interação antes de escrever o gesto

A tarefa do leitor é simples: adicionar a um app para Mac um seletor de tamanho horizontal personalizado. Os tamanhos disponíveis são 20, 40, 60, 80 e 100 pontos. Arrastar pré-visualiza uma seleção ajustada; soltar aplica esse tamanho a um círculo de demonstração. Tudo fica na memória.

Primeiro, decida se o desenho personalizado é necessário. O Slider padrão da Apple já oferece suporte a incrementos discretos por meio do parâmetro step e atualiza o valor associado à medida que o botão deslizante se move. Para um seletor de tamanho convencional, um intervalo de 20...100 com um passo de 20 expressa diretamente essas opções. Use esse controle compatível quando sua aparência e interação atenderem à tarefa. Documentação do Slider da Apple

A implementação personalizada abaixo é útil quando você precisa controlar a trilha, as marcas de escala, o botão deslizante e o comportamento ao soltar. Ela usa deliberadamente um índice inteiro para a seleção. O tamanho exibido vem de um array, então o modelo não pode conter por acidente um tamanho intermediário, como 57.3.

Três decisões definem este exemplo:

  • As posições das marcas de escala são igualmente espaçadas, com os tamanhos maiores à direita.
  • Tanto o botão deslizante quanto o rótulo do valor do controle se ajustam durante o arraste.
  • O tamanho selecionado pelo elemento pai muda ao soltar; durante a pré-visualização, o círculo mantém o tamanho confirmado anteriormente.

Converta a posição do ponteiro em um índice discreto

Mantenha a geometria de entrada e a de desenho consistentes. Aqui, o controle tem 336 pontos de largura e o botão deslizante tem um raio de 12 pontos. Portanto, o centro se desloca de 12 a 324, resultando em um comprimento útil da trilha de 312 pontos. Usar a largura total no cálculo da entrada faria com que o ponteiro e as posições das marcas de escala nas extremidades não coincidissem.

O mapeamento tem quatro operações: subtrair o recuo esquerdo, dividir pelo comprimento útil da trilha, limitar ao intervalo 0...1 e multiplicar pelo número de intervalos antes de arredondar. Cinco opções têm quatro intervalos. O mapeamento inverso posiciona o botão deslizante na marca de escala correspondente.

A função auxiliar a seguir faz parte do app completo montado na próxima seção. Sua regra de arredondamento escolhe o índice mais alto no ponto médio exato, pois os índices normalizados não são negativos. A documentação da Swift descreve .toNearestOrAwayFromZero como arredondamento para o valor mais próximo, com empates afastando-se de zero. Documentação de rounded(_:) da Apple

import SwiftUI

enum SnapGeometry {
    static func index(
        at x: CGFloat,
        width: CGFloat,
        inset: CGFloat,
        count: Int
    ) -> Int {
        guard count > 1, x.isFinite, width > 2 * inset else {
            return 0
        }
        let fraction = min(max((x - inset) / (width - 2 * inset), 0), 1)
        return Int(
            (fraction * CGFloat(count - 1))
                .rounded(.toNearestOrAwayFromZero)
        )
    }

    static func position(
        for index: Int,
        width: CGFloat,
        inset: CGFloat,
        count: Int
    ) -> CGFloat {
        guard count > 1 else { return width / 2 }
        return inset + CGFloat(index) / CGFloat(count - 1)
            * (width - 2 * inset)
    }
}

Estas entradas ilustrativas mostram o comportamento nos limites para a geometria fixa. A limitação mantém o arraste além de qualquer extremidade em uma opção válida; ela não descarta a interação.

Coordenada x do ponteiro Índice selecionado Tamanho em pontos
-10 0 20
12 0 20
51 1 40
90 1 40
168 2 60
324 4 100
350 4 100

Em 51, o ponteiro está exatamente no ponto médio entre as duas primeiras marcas de escala, então a regra seleciona o índice 1. Conferi esses cálculos determinísticos com Python. Não havia um compilador Swift disponível no ambiente de pesquisa, então o app SwiftUI abaixo foi inspecionado, mas não foi compilado nem executado aqui.

Crie o controle para macOS e a visualização que o contém

Crie um projeto de app para macOS no Xcode usando SwiftUI e uma versão mínima de implantação do macOS 13 ou posterior. Substitua a declaração de app e a visualização de conteúdo geradas pelo código abaixo e inclua a função auxiliar anterior no mesmo arquivo Swift. A função auxiliar e os dois blocos seguintes formam juntos um programa completo; mantenha apenas uma declaração de app @main.

O elemento pai mantém o índice confirmado em @State. O controle recebe um @Binding, que o conecta a essa fonte da verdade sem criar uma segunda seleção persistente. Isso segue o padrão de propriedade documentado pela Apple para bindings. Documentação de Binding da Apple

@main
struct SnapSizeApp: App {
    var body: some Scene {
        WindowGroup {
            SizeDemo()
        }
    }
}

struct SizeDemo: View {
    private let sizes = [20, 40, 60, 80, 100]
    @State private var selectedIndex = 2

    var body: some View {
        VStack(spacing: 24) {
            Text("Circle size")
                .font(.headline)

            Circle()
                .fill(Color.accentColor)
                .frame(
                    width: CGFloat(sizes[selectedIndex]),
                    height: CGFloat(sizes[selectedIndex])
                )
                .frame(width: 120, height: 120)

            DiscreteSizeControl(
                sizes: sizes,
                selection: $selectedIndex
            )

            Text("Applied size: \(sizes[selectedIndex]) pt")
        }
        .padding(32)
        .frame(minWidth: 420, minHeight: 340)
    }
}

O controle usa @GestureState para a pré-visualização temporária. A Apple documenta que o estado do gesto volta ao valor inicial quando o gesto fica inativo. Aqui, retornar a nil faz o controle exibir novamente o binding confirmado, então não é necessário limpar manualmente uma seleção temporária em uma variável persistente separada. Documentação de GestureState da Apple

struct DiscreteSizeControl: View {
    let sizes: [Int]
    @Binding var selection: Int
    @GestureState private var previewIndex: Int? = nil

    private let trackWidth: CGFloat = 336
    private let radius: CGFloat = 12

    private var shownIndex: Int {
        previewIndex ?? selection
    }

    private func snappedIndex(at x: CGFloat) -> Int {
        SnapGeometry.index(
            at: x, width: trackWidth,
            inset: radius, count: sizes.count
        )
    }

    private func xPosition(for index: Int) -> CGFloat {
        SnapGeometry.position(
            for: index, width: trackWidth,
            inset: radius, count: sizes.count
        )
    }

    private func moveSelection(by amount: Int) {
        guard previewIndex == nil else { return }
        selection = min(max(selection + amount, 0), sizes.count - 1)
    }

    var body: some View {
        VStack(spacing: 10) {
            Text("Selected size: \(sizes[shownIndex]) pt")
                .monospacedDigit()

            ZStack(alignment: .topLeading) {
                Capsule()
                    .fill(Color.secondary.opacity(0.25))
                    .frame(width: trackWidth - 2 * radius, height: 4)
                    .position(x: trackWidth / 2, y: 20)

                ForEach(sizes.indices, id: \.self) { index in
                    Circle()
                        .fill(Color.secondary)
                        .frame(width: 5, height: 5)
                        .position(x: xPosition(for: index), y: 20)
                }

                Circle()
                    .fill(Color.accentColor)
                    .frame(width: 2 * radius, height: 2 * radius)
                    .position(x: xPosition(for: shownIndex), y: 20)
            }
            .frame(width: trackWidth, height: 40)
            .contentShape(Rectangle())
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .updating($previewIndex) { value, state, _ in
                        state = snappedIndex(at: value.location.x)
                    }
                    .onEnded { value in
                        selection = snappedIndex(at: value.location.x)
                    }
            )
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("Circle size")
            .accessibilityValue("\(sizes[shownIndex]) points")
            .accessibilityAdjustableAction { direction in
                switch direction {
                case .increment:
                    moveSelection(by: 1)
                case .decrement:
                    moveSelection(by: -1)
                @unknown default:
                    break
                }
            }

            HStack {
                Button("Smaller") { moveSelection(by: -1) }
                    .disabled(selection == 0 || previewIndex != nil)
                Button("Larger") { moveSelection(by: 1) }
                    .disabled(
                        selection == sizes.count - 1 || previewIndex != nil
                    )
            }
        }
    }
}

Este exemplo tem pré-requisitos explícitos: o array de tamanhos não está vazio, o índice confirmado é válido e o array permanece fixo durante uma interação. O elemento pai fornecido atende aos três. O valor de retorno preventivo da função auxiliar de geometria não torna válido um array vazio para o controle, pois o rótulo ainda acessa esse array por índice. Se as opções vierem de um modelo que muda, valide a coleção e a seleção antes de renderizar e defina o que acontece quando as opções mudam durante um arraste.

Mantenha o gesto vinculado à geometria estável do controle

O arraste fica no contêiner estacionário da trilha, que tem 336 pontos. O botão deslizante se move dentro desse contêiner. A Apple define location como a localização do evento atual e permite que DragGesture especifique o espaço de coordenadas usado para as localizações. Usar .local no contêiner estável faz com que a coordenada x informada corresponda aos cálculos de desenho. Documentação de DragGesture da Apple, documentação de location da Apple

A forma de conteúdo retangular disponibiliza a área da trilha para detecção de toques, inclusive os espaços entre as formas desenhadas. A distância mínima zero permite reconhecer o gesto sem exigir um limiar inicial de deslocamento. Neste projeto, pressionar outra parte da trilha pré-visualiza imediatamente a marca de escala mais próxima. Esse salto para a posição é intencional. Documentação de contentShape(_:eoFill:) da Apple, documentação de DragGesture da Apple

Para uma interação limitada ao botão deslizante que preserve o deslocamento inicial do ponto de contato, use outro mapeamento: capture uma vez a posição inicial do centro do botão deslizante e, em seguida, some a translação horizontal total do gesto antes de ajustar a posição. Não some repetidamente a translação total a um centro que já foi atualizado. A SwiftUI documenta a translação como o deslocamento desde o início do gesto, não como o movimento desde a chamada de retorno anterior. Documentação de translation da Apple

Não vincule o gesto à pilha ao redor de rótulos e botões. Vincular um gesto afeta as relações de reconhecimento com a visualização vinculada e suas subvisualizações; ampliar esse escopo pode gerar concorrência com outros controles. Se você incorporar este exemplo a uma visualização rolável, teste explicitamente o reconhecimento nesse contexto. Um arraste com distância zero é uma interação imediata intencional, então um painel independente é o ponto de partida mais claro. Documentação de gesture(_:including:) da Apple

Decida quando a pré-visualização vira estado confirmado

Durante o arraste, só mudam o botão deslizante e o rótulo do valor selecionado do controle. O círculo maior e seu rótulo de valor aplicado seguem o índice confirmado do elemento pai. Ao soltar, o manipulador calcula o índice final a partir da localização final, em vez de depender da chamada de retorno de pré-visualização mais recente.

Se o gesto ficar inativo sem essa confirmação ao soltar, a pré-visualização temporária será redefinida e a seleção confirmada anteriormente será mantida. Esse comportamento decorre de manter o estado de pré-visualização em @GestureState e fazer a gravação persistente no manipulador de término. Documentação de GestureState da Apple

Para aplicar a mudança em tempo real, grave o índice ajustado no binding durante as alterações do arraste. Isso muda o contrato da interação: as opções intermediárias passam a ser atualizações confirmadas do modelo. Decida se esse comportamento é desejável antes de conectar o seletor a um trabalho de renderização mais complexo. Em qualquer projeto, desenhe o botão deslizante com base no mesmo índice quantizado usado para o valor selecionado.

Os botões Smaller e Larger aplicam imediatamente mudanças de um passo e param nas extremidades. A trilha também oferece uma ação de acessibilidade ajustável, cujos manipuladores de incremento e decremento usam a mesma função de avanço. A Apple documenta esse modificador como o mecanismo que permite que tecnologias assistivas, como o VoiceOver, acionem ações ajustáveis. Documentação de accessibilityAdjustableAction(_:) da Apple

Quando uma visualização AppKit controla a interação

Uma visualização puramente em SwiftUI não precisa de uma ponte para AppKit para esta tarefa. Se a trilha já estiver em seu próprio NSView, mantenha o reconhecimento nessa visualização. O addGestureRecognizer(_:) da Apple associa um reconhecedor a uma visualização, limita o gesto representado a essa visualização e suas subvisualizações e estabelece uma referência forte ao reconhecedor. Documentação de addGestureRecognizer(_:) da Apple

Para uma implementação em AppKit que use NSPanGestureRecognizer, meça a translação no sistema de coordenadas da visualização estacionária da trilha. Capture a posição inicial do botão deslizante, some a translação total informada e passe a coordenada x resultante pelo mesmo cálculo de ajuste. A Apple documenta explicitamente que translation(in:) retorna a distância total desde o início do gesto, com a transformação da visualização escolhida aplicada. Documentação de translation(in:) da Apple

Use uma implementação de gesto para cada superfície de interação. Se uma trilha AppKit estiver incorporada à SwiftUI, deixe que ela controle seu próprio arraste e informe as mudanças de seleção por meio da conexão do modelo do app. Isso mantém claro o caminho das coordenadas do evento à pré-visualização e à seleção confirmada.

Confira as extremidades, os limiares e os arrastes interrompidos

Verifique a interação no app para Mac com uma sequência curta e concreta:

  1. Comece na marca de escala central. Arraste por todos os intervalos e confirme que o rótulo selecionado mostra apenas os cinco tamanhos permitidos.
  2. Mova além de ambas as extremidades horizontais. O botão deslizante deve permanecer na primeira ou na última marca de escala.
  3. Solte entre marcas de escala. O círculo deve adotar o tamanho mais próximo, correspondente à posição final do botão deslizante.
  4. Clique em uma parte desocupada da trilha. Confirme se o comportamento de salto para a posição atende à tarefa.
  5. Interrompa uma interação ativa e confira se não resta nenhuma pré-visualização temporária presa.
  6. Use os dois botões de avanço e os ajustes do VoiceOver. Confirme que param nas mesmas extremidades.

Para opções desiguais, como 20, 30, 60 e 100, este projeto baseado em índice ainda espaça as opções igualmente. Isso define deliberadamente um layout ordinal. Se o espaçamento precisar refletir a distância numérica, mapeie cada valor numérico à sua própria posição na trilha e selecione a posição mais próxima; arredondar um índice uniforme representa outra interação.

Um controle de ajuste confiável usa um único sistema de coordenadas e um único mapeamento para a entrada e o desenho. Com ambos alinhados, um índice inteiro mantém cada valor selecionado discreto, enquanto separar o estado de pré-visualização do estado confirmado deixa explícito o comportamento ao soltar.

Fontes