Como imitar a folha de baixo do aplicativo Maps?


202

Alguém pode me dizer como eu posso imitar a folha de baixo no novo aplicativo Maps no iOS 10?

No Android, você pode usar um BottomSheetque imita esse comportamento, mas não consegui encontrar nada parecido no iOS.

É uma visualização de rolagem simples com um conteúdo inserido, de modo que a barra de pesquisa fique na parte inferior?

Eu sou bastante novo na programação do iOS, portanto, se alguém pudesse me ajudar a criar esse layout, isso seria muito apreciado.

Isto é o que quero dizer com "folha de baixo":

captura de tela da folha inferior recolhida no Maps

captura de tela da planilha inferior expandida no Google Maps


1
Você tem que construir você mesmo, eu tenho medo. É uma visão com visão de fundo desfocado e alguns reconhecedores de gestos.
precisa

@KhanhNguyen sim, eu sei que tenho que construí-lo, mas o que eu estou querendo saber é que as visões são usados para archieve este efeito e como eles são ordenados e assim por diante
qwertz

É visualização de efeito visual com efeito de desfoque. Veja esta pergunta SO
Khanh Nguyen 22/06

1
Acho que você pode adicionar um reconhecedor de gestos de panorâmica à vista inferior e movê-la de acordo (observando as alterações armazenando as coordenadas y entre os eventos do reconhecedor de gestos e calcular a diferença).
precisa

2
Eu gosto das suas perguntas. Estou planejando implementar algo assim. Se alguém pudesse escrever um exemplo, seria legal.
Wwiana

Respostas:


330

Não sei exatamente como a parte inferior do novo aplicativo do Google Maps responde às interações do usuário. Mas você pode criar uma exibição personalizada que se pareça com a das capturas de tela e adicioná-la à exibição principal.

Presumo que você saiba como:

1- crie controladores de exibição por storyboards ou usando arquivos xib.

2- use o googleMaps ou o MapKit da Apple.

Exemplo

1- Crie 2 controladores de exibição, por exemplo, MapViewController e BottomSheetViewController . O primeiro controlador hospedará o mapa e o segundo é a própria folha de baixo.

Configurar o MapViewController

Crie um método para adicionar a visualização da folha inferior.

func addBottomSheetView() {
    // 1- Init bottomSheetVC
    let bottomSheetVC = BottomSheetViewController()

    // 2- Add bottomSheetVC as a child view 
    self.addChildViewController(bottomSheetVC)
    self.view.addSubview(bottomSheetVC.view)
    bottomSheetVC.didMoveToParentViewController(self)

    // 3- Adjust bottomSheet frame and initial position.
    let height = view.frame.height
    let width  = view.frame.width
    bottomSheetVC.view.frame = CGRectMake(0, self.view.frame.maxY, width, height)
}

E chame-o no método viewDidAppear:

override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)
    addBottomSheetView()
}

Configurar BottomSheetViewController

1) Preparar o histórico

Crie um método para adicionar efeitos de desfoque e vibração

func prepareBackgroundView(){
    let blurEffect = UIBlurEffect.init(style: .Dark)
    let visualEffect = UIVisualEffectView.init(effect: blurEffect)
    let bluredView = UIVisualEffectView.init(effect: blurEffect)
    bluredView.contentView.addSubview(visualEffect)

    visualEffect.frame = UIScreen.mainScreen().bounds
    bluredView.frame = UIScreen.mainScreen().bounds

    view.insertSubview(bluredView, atIndex: 0)
}

chame esse método na sua viewWillAppear

override func viewWillAppear(animated: Bool) {
   super.viewWillAppear(animated)
   prepareBackgroundView()
}

Certifique-se de que a cor de fundo da visualização do seu controlador seja clara.

2) Animar aparência bottomSheet

override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)

    UIView.animateWithDuration(0.3) { [weak self] in
        let frame = self?.view.frame
        let yComponent = UIScreen.mainScreen().bounds.height - 200
        self?.view.frame = CGRectMake(0, yComponent, frame!.width, frame!.height)
    }
}

3) Modifique seu xib como desejar.

4) Adicione o Pan Gesture Recognizer à sua exibição.

No seu método viewDidLoad, adicione UIPanGestureRecognizer.

override func viewDidLoad() {
    super.viewDidLoad()

    let gesture = UIPanGestureRecognizer.init(target: self, action: #selector(BottomSheetViewController.panGesture))
    view.addGestureRecognizer(gesture)

}

E implemente seu comportamento gestual:

func panGesture(recognizer: UIPanGestureRecognizer) {
    let translation = recognizer.translationInView(self.view)
    let y = self.view.frame.minY
    self.view.frame = CGRectMake(0, y + translation.y, view.frame.width, view.frame.height)
     recognizer.setTranslation(CGPointZero, inView: self.view)
}

Folha inferior rolável:

Se sua visualização personalizada for uma visualização de rolagem ou qualquer outra visualização herdada, você terá duas opções:

Primeiro:

Projete a vista com uma vista de cabeçalho e adicione o panGesture ao cabeçalho. (má experiência do usuário) .

Segundo:

1 - Adicione o panGesture à vista de folha inferior.

2 - Implemente o UIGestureRecognizerDelegate e defina o delegado panGesture no controlador.

3- Implemente a função delegar shouldRecognizeSimultaneousWith e desabilite a propriedade scrollView isScrollEnabled em dois casos:

  • A vista é parcialmente visível.
  • A visualização é totalmente visível, a propriedade scrollView contentOffset é 0 e o usuário está arrastando a visualização para baixo.

Caso contrário, ative a rolagem.

  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
      let gesture = (gestureRecognizer as! UIPanGestureRecognizer)
      let direction = gesture.velocity(in: view).y

      let y = view.frame.minY
      if (y == fullView && tableView.contentOffset.y == 0 && direction > 0) || (y == partialView) {
          tableView.isScrollEnabled = false
      } else {
        tableView.isScrollEnabled = true
      }

      return false
  }

NOTA

Caso você defina .allowUserInteraction como uma opção de animação, como no projeto de amostra, será necessário ativar a rolagem no fechamento da conclusão da animação se o usuário estiver rolando para cima.

Projeto de exemplo

Criei um projeto de amostra com mais opções neste repositório, o que pode lhe fornecer informações melhores sobre como personalizar o fluxo.

Na demonstração, a função addBottomSheetView () controla qual visualização deve ser usada como uma folha inferior.

Exemplo de capturas de tela do projeto

- Vista parcial

insira a descrição da imagem aqui

- Vista completa

insira a descrição da imagem aqui

- Visualização rolável

insira a descrição da imagem aqui


3
Este é realmente um bom tutorial para childViews e subViews. Obrigado :)
Edison

@AhmedElassuty como posso adicionar tableview no xib e carregar como folha inferior? Se você pudesse ajudar seria ótimo Agradecemos antecipadamente!
Nikhil nangia

3
@nikhilnangia Acabei de atualizar o repositório com outro viewController "ScrollableBottomSheetViewController.swift" que contém um tableView. Vou atualizar a resposta o mais rápido possível. Verifique isso também github.com/AhmedElassuty/IOS-BottomSheet/issues/1
Ahmad Elassuty 16/16

12
Percebi que a folha inferior do Apple Maps lida com a transição do gesto de arrastar a folha para o gesto de rolagem da exibição de rolagem em um movimento suave, ou seja, o usuário não precisa parar um gesto e iniciar um novo para começar a rolar a exibição. Seu exemplo não faz isso. Você tem alguma idéia de como isso pode ser feito? Obrigado e muito obrigado por postar o exemplo em um repo!
28517 Stoph

2
@ RafaelaLourenço Estou muito feliz que você tenha achado minha resposta útil! Obrigado!
Ahmad Elassuty

55

Lancei uma biblioteca com base na minha resposta abaixo.

Ele imita a sobreposição do aplicativo Atalhos. Veja este artigo para detalhes.

O principal componente da biblioteca é o OverlayContainerViewController. Ele define uma área na qual um controlador de exibição pode ser arrastado para cima e para baixo, ocultando ou revelando o conteúdo abaixo dele.

let contentController = MapsViewController()
let overlayController = SearchViewController()

let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
    contentController,
    overlayController
]

window?.rootViewController = containerController

Implemente OverlayContainerViewControllerDelegatepara especificar o número de entalhes desejados:

enum OverlayNotch: Int, CaseIterable {
    case minimum, medium, maximum
}

func numberOfNotches(in containerViewController: OverlayContainerViewController) -> Int {
    return OverlayNotch.allCases.count
}

func overlayContainerViewController(_ containerViewController: OverlayContainerViewController,
                                    heightForNotchAt index: Int,
                                    availableSpace: CGFloat) -> CGFloat {
    switch OverlayNotch.allCases[index] {
        case .maximum:
            return availableSpace * 3 / 4
        case .medium:
            return availableSpace / 2
        case .minimum:
            return availableSpace * 1 / 4
    }
}

Resposta anterior

Eu acho que há um ponto significativo que não é tratado nas soluções sugeridas: a transição entre o pergaminho e a tradução.

Transição de mapas entre o pergaminho e a tradução

No Maps, como você deve ter notado, quando o tableView chega contentOffset.y == 0, a folha inferior desliza para cima ou para baixo.

A questão é complicada, porque não podemos simplesmente ativar / desativar a rolagem quando nosso gesto de panorâmica inicia a tradução. Pararia o pergaminho até um novo toque começar. Este é o caso na maioria das soluções propostas aqui.

Aqui está minha tentativa de implementar esta moção.

Ponto de partida: aplicativo do Google Maps

Para iniciar nossa investigação, vamos visualizar a vista hierarquia de Mapas (iniciar Mapas em um simulador e selecione Debug> Attach to process by PID or Name> Mapsno Xcode 9).

Hierarquia de exibição de depuração do Google Maps

Não diz como o movimento funciona, mas me ajudou a entender a lógica dele. Você pode jogar com o lldb e o depurador da hierarquia de visualizações.

Nossas pilhas de controladores de exibição

Vamos criar uma versão básica da arquitetura do Maps ViewController.

Começamos com um BackgroundViewController(nossa visualização de mapa):

class BackgroundViewController: UIViewController {
    override func loadView() {
        view = MKMapView()
    }
}

Colocamos o tableView em um dedicado UIViewController:

class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    lazy var tableView = UITableView()

    override func loadView() {
        view = tableView
        tableView.dataSource = self
        tableView.delegate = self
    }

    [...]
}

Agora, precisamos de um VC para incorporar a sobreposição e gerenciar sua tradução. Para simplificar o problema, consideramos que ele pode converter a sobreposição de um ponto estático OverlayPosition.maximumpara outro OverlayPosition.minimum.

Por enquanto, ele possui apenas um método público para animar a mudança de posição e possui uma visão transparente:

enum OverlayPosition {
    case maximum, minimum
}

class OverlayContainerViewController: UIViewController {

    let overlayViewController: OverlayViewController
    var translatedViewHeightContraint = ...

    override func loadView() {
        view = UIView()
    }

    func moveOverlay(to position: OverlayPosition) {
        [...]
    }
}

Finalmente, precisamos de um ViewController para incorporar tudo:

class StackViewController: UIViewController {

    private var viewControllers: [UIViewController]

    override func viewDidLoad() {
        super.viewDidLoad()
        viewControllers.forEach { gz_addChild($0, in: view) }
    }
}

Em nosso AppDelegate, nossa sequência de inicialização se parece com:

let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])

A dificuldade por trás da tradução da sobreposição

Agora, como traduzir nossa sobreposição?

A maioria das soluções propostas usa um reconhecedor de gesto de panorâmica dedicado, mas na verdade já temos um: o gesto de panorâmica da exibição da tabela. Além disso, precisamos manter o pergaminho e a tradução sincronizados e UIScrollViewDelegatetodos os eventos de que precisamos!

Uma implementação ingênua usaria um segundo gesto de panorâmica e tentaria redefinir a contentOffsetexibição da tabela quando a tradução ocorrer:

func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
    if isTranslating {
        tableView.contentOffset = .zero
    }
}

Mas não funciona. O tableView atualiza contentOffsetquando sua própria ação do reconhecedor de gestos de movimento é acionada ou quando seu retorno de chamada displayLink é chamado. Não há nenhuma chance de que nosso reconhecedor seja acionado logo após aqueles para substituir com êxito o contentOffset. Nossa única chance é participar da fase de layout (substituindo layoutSubviewsas chamadas de exibição de rolagem em cada quadro da exibição de rolagem) ou responder ao didScrollmétodo do delegado chamado toda vez que a contentOffsetmodificação for modificada. Vamos tentar este.

A tradução Implementation

Adicionamos um delegado ao nosso OverlayVCpara despachar os eventos do scrollview ao nosso manipulador de traduções, o OverlayContainerViewController:

protocol OverlayViewControllerDelegate: class {
    func scrollViewDidScroll(_ scrollView: UIScrollView)
    func scrollViewDidStopScrolling(_ scrollView: UIScrollView)
}

class OverlayViewController: UIViewController {

    [...]

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        delegate?.scrollViewDidScroll(scrollView)
    }

    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        delegate?.scrollViewDidStopScrolling(scrollView)
    }
}

Em nosso contêiner, acompanhamos a tradução usando uma enumeração:

enum OverlayInFlightPosition {
    case minimum
    case maximum
    case progressing
}

O cálculo da posição atual é semelhante a:

private var overlayInFlightPosition: OverlayInFlightPosition {
    let height = translatedViewHeightContraint.constant
    if height == maximumHeight {
        return .maximum
    } else if height == minimumHeight {
        return .minimum
    } else {
        return .progressing
    }
}

Precisamos de 3 métodos para lidar com a tradução:

O primeiro diz-nos se precisamos iniciar a tradução.

private func shouldTranslateView(following scrollView: UIScrollView) -> Bool {
    guard scrollView.isTracking else { return false }
    let offset = scrollView.contentOffset.y
    switch overlayInFlightPosition {
    case .maximum:
        return offset < 0
    case .minimum:
        return offset > 0
    case .progressing:
        return true
    }
}

O segundo realiza a tradução. Ele usa o translation(in:)método do gesto de panorâmica do scrollView.

private func translateView(following scrollView: UIScrollView) {
    scrollView.contentOffset = .zero
    let translation = translatedViewTargetHeight - scrollView.panGestureRecognizer.translation(in: view).y
    translatedViewHeightContraint.constant = max(
        Constant.minimumHeight,
        min(translation, Constant.maximumHeight)
    )
}

O terceiro anima o final da tradução quando o usuário solta o dedo. Calculamos a posição usando a velocidade e a posição atual da vista.

private func animateTranslationEnd() {
    let position: OverlayPosition =  // ... calculation based on the current overlay position & velocity
    moveOverlay(to: position)
}

A implementação delegada da nossa sobreposição simplesmente se parece com:

class OverlayContainerViewController: UIViewController {

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard shouldTranslateView(following: scrollView) else { return }
        translateView(following: scrollView)
    }

    func scrollViewDidStopScrolling(_ scrollView: UIScrollView) {
        // prevent scroll animation when the translation animation ends
        scrollView.isEnabled = false
        scrollView.isEnabled = true
        animateTranslationEnd()
    }
}

Problema final: despachar os toques do contêiner de sobreposição

A tradução agora é bastante eficiente. Mas ainda há um problema final: os retoques não são entregues à nossa visão de segundo plano. Todos eles são interceptados pela visão do contêiner de sobreposição. Não podemos definir isUserInteractionEnabledcomo, falseporque isso também desativaria a interação em nossa visualização de tabela. A solução é aquela usada maciçamente no aplicativo Maps PassThroughView:

class PassThroughView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let view = super.hitTest(point, with: event)
        if view == self {
            return nil
        }
        return view
    }
}

Ele se remove da cadeia de respostas.

Em OverlayContainerViewController:

override func loadView() {
    view = PassThroughView()
}

Resultado

Aqui está o resultado:

Resultado

Você pode encontrar o código aqui .

Por favor, se você encontrar algum erro, me avise! Observe que sua implementação pode, é claro, usar um segundo gesto de panorâmica, especialmente se você adicionar um cabeçalho em sua sobreposição.

Atualização 23/08/18

Podemos substituir scrollViewDidEndDraggingpor, em willEndScrollingWithVelocityvez de enabling/ disablingo rolo, quando o usuário terminar de arrastar:

func scrollView(_ scrollView: UIScrollView,
                willEndScrollingWithVelocity velocity: CGPoint,
                targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    switch overlayInFlightPosition {
    case .maximum:
        break
    case .minimum, .progressing:
        targetContentOffset.pointee = .zero
    }
    animateTranslationEnd(following: scrollView)
}

Podemos usar uma animação de primavera e permitir a interação do usuário durante a animação para melhorar o fluxo do movimento:

func moveOverlay(to position: OverlayPosition,
                 duration: TimeInterval,
                 velocity: CGPoint) {
    overlayPosition = position
    translatedViewHeightContraint.constant = translatedViewTargetHeight
    UIView.animate(
        withDuration: duration,
        delay: 0,
        usingSpringWithDamping: velocity.y == 0 ? 1 : 0.6,
        initialSpringVelocity: abs(velocity.y),
        options: [.allowUserInteraction],
        animations: {
            self.view.layoutIfNeeded()
    }, completion: nil)
}

Essa abordagem não é interativa durante as etapas de animação. Por exemplo, no Maps, posso pegar a folha com o dedo enquanto ela está se expandindo. Posso então esfregar a animação deslizando para cima ou para baixo. Ele retornará para o local mais próximo. Acredito que isso possa ser resolvido usando UIViewPropertyAnimator(que tem a capacidade de pausar) e use a fractionCompletepropriedade para executar a limpeza.
aust

1
Você está certo @ aust. Eu esqueci de empurrar minha alteração anterior. Deve estar bom agora. Obrigado.
GaétanZ

Essa é uma ótima idéia, no entanto, vejo um problema imediatamente. No translateView(following:)método, você calcula a altura com base na conversão, mas isso pode começar com um valor diferente de 0,0 (por exemplo, a exibição da tabela é rolada um pouco e a sobreposição é maximizada quando você começa a arrastar) . Isso resultaria no salto inicial. Você poderia resolver isso pelo scrollViewWillBeginDraggingretorno de chamada, onde lembraria a inicial contentOffsetda exibição da tabela e a usaria no translateView(following:)cálculo da.
Jakub Truhlář

1
@ GaétanZ Eu também estava interessado em anexar o depurador ao Maps.app no ​​Simulator, mas recebi o erro "Não foi possível anexar ao pid:" 9276 "" seguido de "Verifique se o" Maps "ainda não está sendo executado e o <username> tem permissão para depure ". - Como você enganou o Xcode para permitir a conexão com o Maps.app?
matm

1
O @matm @ GaétanZ trabalhou para mim no Xcode 10.1 / Mojave -> só é necessário desativar para desativar o SIP (System Integrity Protection). Você precisa: 1) Reinicie seu mac; 2) Mantenha pressionado cmd + R; 3) No menu, selecione Utilitários e Terminal; 4) No tipo de janela Terminal: csrutil disable && reboot. Você terá todos os poderes que o root normalmente fornecerá, incluindo a capacidade de se conectar a um processo da Apple.
Valdyr

18

Tente Polia :

O Polia é uma biblioteca de gavetas fácil de usar, criada para imitar a gaveta no aplicativo Maps do iOS 10. Ele expõe uma API simples que permite usar qualquer subclasse UIViewController como o conteúdo da gaveta ou o conteúdo principal.

Visualização da polia

https://github.com/52inc/Pulley


1
Ele suporta rolagem na lista subjacente?
Richard Lindhout

@RichardLindhout - Depois de executar a demonstração, parece que ela suporta rolagem, mas não a transição suave de mover a gaveta para rolar a lista.
Stoph 29/08

O exemplo parece ocultar o link legal da Apple no canto inferior esquerdo.
Gerd Castan

1
Pergunta super simples, como você conseguiu o indicador no topo da folha de baixo?
A Israfil

12

Você pode tentar minha resposta https://github.com/SCENEE/FloatingPanel . Ele fornece um controlador de exibição de contêiner para exibir uma interface de "folha inferior".

É fácil de usar e você não se importa com o manuseio do reconhecedor de gestos! Além disso, você pode acompanhar a exibição de uma rolagem (ou a exibição de irmãos) em uma folha inferior, se necessário.

Este é um exemplo simples. Observe que você precisa preparar um controlador de exibição para exibir seu conteúdo em uma folha inferior.

import UIKit
import FloatingPanel

class ViewController: UIViewController {
    var fpc: FloatingPanelController!

    override func viewDidLoad() {
        super.viewDidLoad()

        fpc = FloatingPanelController()

        // Add "bottom sheet" in self.view.
        fpc.add(toParent: self)

        // Add a view controller to display your contents in "bottom sheet".
        let contentVC = ContentViewController()
        fpc.set(contentViewController: contentVC)

        // Track a scroll view in "bottom sheet" content if needed.
        fpc.track(scrollView: contentVC.tableView)    
    }
    ...
}

Aqui está outro exemplo de código para exibir uma folha inferior e procurar um local como o Apple Maps.

Exemplo de aplicativo como o Apple Maps


fpc.set (contentViewController: newsVC), método ausente na biblioteca
Faris Muhammed

1
Pergunta super simples, como você conseguiu o indicador no topo da folha de baixo?
A Israfil

1
@AIsrafil Eu acho que é apenas um UIView
ShadeToD

Eu acho que é uma solução muito boa, mas há um problema no iOS 13 com a apresentação do controlador de exibição modal com este painel flutuante.
ShadeToD 6/03

12

Escrevi minha própria biblioteca para obter o comportamento pretendido no aplicativo ios Maps. É uma solução orientada a protocolo. Portanto, você não precisa herdar nenhuma classe base, em vez disso, crie um controlador de folha e configure como desejar. Ele também suporta navegação / apresentação interna com ou sem o UINavigationController.

Veja o link abaixo para mais detalhes.

https://github.com/OfTheWolf/UBottomSheet

folha inferior


ESTA deve ser a resposta selecionada, pois você ainda pode clicar no VC atrás do pop-up.
Jay

Pergunta super simples, como você conseguiu o indicador no topo da folha de baixo?
A Israfil

1

Recentemente, criei um componente chamado SwipeableViewcomo subclasse de UIView, escrito no Swift 5.1. Ele suporta todas as quatro direções, possui várias opções de personalização e pode animar e interpolar diferentes atributos e itens (como restrições de layout, cor de fundo / tonalidade, transformação afim, canal alfa e centro de exibição, todos demonstrados com o respectivo caso de exibição). Ele também suporta a coordenação de deslizar com a visualização de rolagem interna, se configurada ou detectada automaticamente. Deve ser bastante fácil e direto de usar (espero 🙂)

Link em https://github.com/LucaIaco/SwipeableView

prova de conceito:

insira a descrição da imagem aqui

Espero que ajude


0

Talvez você possa tentar minha resposta https://github.com/AnYuan/AYPannel , inspirada no Pulley. Transição suave de mover a gaveta para rolar a lista. Adicionei um gesto de panorama na exibição de rolagem do contêiner e defina shouldRecognizeSimultaneamenteWithGestureRecognizer para retornar YES. Mais detalhes no meu link do github acima. Deseja ajudar.


6
Embora esse link possa responder à pergunta, é melhor incluir aqui as partes essenciais da resposta e fornecer o link para referência. As respostas somente para links podem se tornar inválidas se a página vinculada for alterada. Embora esse link possa responder à pergunta, é melhor incluir aqui as partes essenciais da resposta e fornecer o link para referência. As respostas somente para links podem se tornar inválidas se a página vinculada for alterada.
Pirho
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.