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:
- Comece na marca de escala central. Arraste por todos os intervalos e confirme que o rótulo selecionado mostra apenas os cinco tamanhos permitidos.
- Mova além de ambas as extremidades horizontais. O botão deslizante deve permanecer na primeira ou na última marca de escala.
- Solte entre marcas de escala. O círculo deve adotar o tamanho mais próximo, correspondente à posição final do botão deslizante.
- Clique em uma parte desocupada da trilha. Confirme se o comportamento de salto para a posição atende à tarefa.
- Interrompa uma interação ativa e confira se não resta nenhuma pré-visualização temporária presa.
- 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
- Slider — Apple
- rounded(_:) — Apple
- Binding — Apple
- GestureState — Apple
- DragGesture — Apple
- location — Apple
- contentShape(_:eoFill:) — Apple
- translation — Apple
- gesture(_:including:) — Apple
- accessibilityAdjustableAction(_:) — Apple
- addGestureRecognizer(_:) — Apple
- translation(in:) — Apple