Swift / SwiftUI

Construindo painéis não modais adaptáveis em SwiftUI

A equipe do aplicativo Strolly, que utiliza extensivamente painéis não modais em SwiftUI, enfrentou o desafio de adaptar sua interface para suportar orientação paisagem e as futuras exigências de redimensionamento no iOS 27. Para resolver isso, eles criaram um novo API de apresentação baseada em overlay que mantém a funcionalidade dos painéis não modais tanto em orientação retrato quanto paisagem.

Compartilhar
Building adaptive non-modal panels in SwiftUI social preview

Construindo painéis não modais adaptáveis em SwiftUI

No nosso aplicativo de caminhada Strolly, fazemos um uso intensivo de folhas não modais com detentes pequenos e médios para que os usuários possam interagir com o mapa e seus controles ao mesmo tempo. Até recentemente, o aplicativo suportava apenas a orientação retrato no iPhone. Como parte da nossa última atualização, queríamos suportar layouts em paisagem em preparação às exigências de redimensionamento do aplicativo que virão com o iOS 27. A API de folha do sistema não preserva sua apresentação não modal quando um iPhone é rotacionado para a orientação paisagem, então tivemos que encontrar outra solução.

Neste post, vamos examinar como construímos uma nova API de apresentação baseada em overlay para um painel semelhante a uma folha e as decisões de design que permitem que ele suporte detentes tanto na orientação retrato quanto paisagem. Presumimos familiaridade com layouts personalizados do SwiftUI e não abordaremos fundamentos de layout.

Define uma API para painéis semelhantes a folhas

Desejávamos que a API do painel se sentisse similar à existente sheet(isPresented:onDismiss:content:) do SwiftUI. Um painel é apresentado com um binding, e seu conteúdo declara os detentes suportados dentro da clausura de apresentação.

struct MapScreen: View {
@State private var isPanelPresented = false
@State private var
selectedDetent = PanelDetent.adaptive

var body: some View {
Map()
.panel(isPresented: $isPanelPresented) {
PanelContent()
.panelDetents(
[.adaptive, .medium, .large],
selection: $selectedDetent
)
}
}
}

O modificador panel(isPresented:content:) coloca um PanelOverlay acima do mapa usando overlay(alignment:content:). Modificadores no conteúdo filho escrevem valores de configuração, como detentes, usando preferences. O PanelOverlay lê esses valores e os passa para o layout enquanto mantém a colocação, arrasto e desmissão. Portanto, o conteúdo filho não precisa saber se aparece na parte inferior ou no limite da cena.

Nosso tipo PanelDetent segue de perto as opções disponíveis em PresentationDetent, com um caso adicional adaptive que ajusta o painel ao seu conteúdo.

enum PanelDetent: Hashable {
case adaptive
case height(CGFloat)
case fraction(CGFloat)
case medium
case large
}

Caso o conteúdo não forneça nenhum detente, o painel usa como padrão adaptive. O padrão adaptativo funciona bem para controles pequenos e exibições de confirmação onde uma altura fixa ou fracionária deixaria espaço desnecessário.

# Adequando-se à largura disponível

Não podemos decidir entre as apresentações do painel apenas com o valor de ambiente horizontalSizeClass da SwiftUI. Modelos menores do iPhone ainda podem reportar .compact após girar para modo paisagem, mesmo quando há espaço suficiente para um painel ao lado do mapa. Se mudássemos apenas com base na classe de tamanho, esses telefones continuariam a usar a apresentação inferior, bloqueando grande parte do mapa.

Ao invés disso, o painel seleciona sua apresentação da largura medida da cena, usando um painel alinhado ao lado quando há espaço suficiente para ambos o painel e uma área útil do mapa. Fazemos essa medição disponível para cada painel aplicando um modificador de observação à vista raiz em nosso WindowGroup. O modificador usa onGeometryChange(for:of:action:) para registrar o tamanho da cena e os recuos de área segura, então escreve ambos os valores no ambiente com environment(_:_:).

WindowGroup {
    MainScreenView()
        .modifier(PanelSceneGeometryModifier())
}

extension EnvironmentValues {
    @Entry var panelSceneSize = CGSize.zero
    @Entry var panelSceneSafeAreaInsets = PanelSafeAreaInsets()
}

private struct PanelSceneGeometryModifier: ViewModifier {
    @State private var sceneGeometry = PanelSceneGeometry()

    func body(content: Content) -> some struct PainelAjusteRegular {
var larguraMinima: CGFloat = 320
var larguraIdeal: CGFloat = 380
var larguraMaxima: CGFloat = 420

func usaModoExibicaoRegular(
larguraDisponivel: CGFloat
) -> Bool {
larguraDisponivel >= larguraMinima * 2 &&
larguraDisponivel > larguraMaxima
}
}

Quando a largura disponível não pode acomodar duas colunas de largura mínima, o painel preenche toda a largura disponível e permanece anexado à borda inferior. Uma vez que haja espaço para tanto o painel quanto o mapa, ele muda para uma apresentação alinhada com a borda no lado direito. Painéis individuais podem substituir essa alinhamento e largura com panelRegularAdaptation(), que emite uma preferência para PainelOverlay usar ao resolver o layout.

Resolvendo detentes da geometria do scene

O painel é medido e posicionado usando um Layout personalizado. Manter essas cálculos dentro do layout é importante porque a altura final depende da proporção de tamanho proposta, o detent selecionado, o conteúdo medido e a área segura disponível na borda atual.

extension PanelDetent {
func alturaIntencionada(
alturaScene: CGFloat,
extensaoDeConteudoAjustavel: CGFloat
) -> CGFloat {
switch self {
case .adaptive:
return extensaoDeConteudoAjustavel
case .height(let altura):
return altura
case .fraction(let fração):
return alturaScene * fração
case .medium:
return alturaScene * 0.5
case .large:
return alturaScene
}
}
}

O detent adaptive é o único caso que requer a medição do conteúdo. Outros detents resolvem diretamente de seus valores configurados e da altura da cena. O layout então limita a altura intencional ao espaço disponível dentro das áreas seguras da cena.

Quando o dispositivo rota, o SwiftUI propõe um novo tamanho para o layout. O layout mede o conteúdo no novo espaço, resolve novamente o detent selecionado e posiciona o painel usando o modo de apresentação para a nova largura. A seleção pode permanecer medium, por exemplo, enquanto sua altura concreta e colocação horizontal mudam.

# Mantendo o painel interativo enquanto arrasta

O gesto de arrastar está anexado tanto ao fundo do painel quanto ao seu conteúdo com simultaneousGesture(_:including:). Anexar o arrasto como um gesto simultâneo permite que controles, como instâncias de ScrollView dentro do painel, continuem recebendo seus próprios gestos em vez de dar prioridade ao arrasto do painel sobre todas as interações.

Após resolver o detent selecionado, a disposição armazena a altura e origem definidas no cache criado com makeCache(subviews:). A parte importante é que updateCache(_:subviews:) não faz nada enquanto um arrasto está em andamento, preservando essas medições definidas durante a duração do gesto.

struct PainelPlacementLayoutCache {
var hasSettledMetrics = false
var
settledAvailableHeight: CGFloat = 0
var settledOrigin = CGPoint.zero
var settledHeight: CGFloat?
}

extension PainelPlacementLayout {
func makeCache(
subviews: Subviews
) -> PainelPlacementLayoutCache {
PainelPlacementLayoutCache()
}

func updateCache(
_ cache: inout PainelPlacementLayoutCache,
subviews: Subviews
) {
guard !isDragging else { return }
cache = makeCache(subviews: subviews)
}
}

Durante um arrasto vertical, a disposição aplica a tradução do gesto aos valores preservados sem medir o conteúdo novamente ou chamar intendedHeight(). Enquanto placeSubviews(in:proposal:subviews:cache:) resolve a colocação, ele armazena os últimos valores definidos no cache, mas apenas enquanto o painel não está sendo arrastado.

if !isDragging {
cache.hasSettledMetrics = true
cache.settledPanelBounds = panelBounds
cache.settledAvailableHeight = availableHeight
cache.settledOrigin = origin
cache.settledHeight = resolvedHeight
cache.settledBackgroundRect = backgroundRect
}

Fuera de um arrasto, cada atualização começa com um novo cache vazio, que preenchemos para o próximo potencial arrasto. O armazenamento em cache do tamanho resolvido é crítico ao lidar com o gesto porque impede que o painel salte em tamanho, especialmente quando usando um detent adaptativo ou conteúdo de tamanho adaptável.

Quando o gesto de arrastar termina, sua clausura onEnded(_:) usa predictedEndTranslation para levar em conta a velocidade do arrasto e escolher o próximo detent. Atualizar o detent selecionado invalida a disposição personalizada, que é executada novamente e posiciona o painel na nova altura de destino. Arrastar abaixo do menor detent desliga o painel a menos que o conteúdo tenha aplicado panelInteractiveDismissDisabled().

# Usando o panel em Strolly

Cada fluxo pode escolher detentes e comportamento de interação sem implementar seu próprio contêiner adaptativo. Por exemplo, o painel de preferências da área começa com uma altura adaptativa e pode ser expandido para o detente médio.

.panel(isPresented: 
$showAreaPreferences
) {
    AreaPreferenceSheetContent()
        .panelDetents([.adaptive, .medium])
}

Outros painéis usam um único detente adaptativo, vinculam sua seleção atual para controle programático, ocultam o indicador de arrasto ou desabilitam a dispensa interativa enquanto uma etapa de onboarding obrigatória está em andamento. Cada modificador de configuração escreve uma preferência, permitindo que a mesma API funcione com apresentações inferiores e alinhadas ao limite.

Criar uma apresentação personalizada significa assumir responsabilidade pelo comportamento fornecido por um painel do sistema, incluindo layout, áreas seguras, gestos e animação. Para Strolly, essa troca nos dá um modelo de apresentação único em rotações de dispositivo e layouts mais largos enquanto preserva a interação com o mapa.

Mantendo a API próxima à API da folha do SwiftUI, devemos ser capazes de migrar para uma apresentação nativa se a Apple fornecer uma que suporte layouts de iPhone redimensionáveis no futuro.

Fonte original

Conteúdo traduzido e adaptado pela redação do Notícias Mobile. Confira também a matéria na fonte original.

Leia a matéria completa