Як я імітую нижній аркуш програми "Карти"?


202

Хтось може мені сказати, як я можу імітувати нижній аркуш у новому додатку Карти в iOS 10?

В Android ви можете використовувати те, BottomSheetщо імітує таку поведінку, але я не зміг знайти щось подібне для iOS.

Це простий вид прокрутки із вставкою вмісту, щоб рядок пошуку знаходився внизу?

Я досить новачок у програмуванні iOS, тому якщо хтось міг би допомогти мені створити цю верстку, це було б дуже вдячно.

Це те, що я маю на увазі під «нижчим аркушем»:

скріншот згорнутого нижнього аркуша в Картах

скріншот розгорнутого нижнього аркуша в Картах


1
Ви мусите це будувати самі, боюся. Це вид із розмитим фоновим видом та деякими розпізнавачами жестів.
Хань Нгуен

@KhanhNguyen Так, я знаю, що я повинен будувати це сам, але мені цікаво, які погляди використовуються для досягнення цього ефекту і як вони впорядковані тощо
qwertz

Це візуальний ефект з ефектом розмиття. Дивіться це питання ТАК
Хан Нгуен

1
Я думаю, що ви можете додати розпізнавач жестів панорами до подання знизу і перемістити подання відповідно (дотримуючись зміни, зберігаючи y координати між подіями розпізнавача жестів, і обчислити різницю).
Хан Нгуен

2
Мені подобаються ваші запитання. Я планую реалізувати щось подібне. Якби хтось міг написати якийсь приклад, було б добре.
wviana

Відповіді:


329

Я не знаю, як саме нижній аркуш нового додатка Maps реагує на взаємодію користувачів. Але ви можете створити спеціальний вид, схожий на той, що на екранах екрана, і додати його до основного виду.

Я припускаю, що ви знаєте, як:

1- створити контролери перегляду або за допомогою розгортки, або за допомогою файлів xib.

2- використовуйте googleMaps або Apple MapKit.

Приклад

1- Створіть 2 контролери перегляду, наприклад, MapViewController та BottomSheetViewController . Перший контролер розмістить карту, а другий - сам нижній аркуш.

Налаштування MapViewController

Створіть метод для додавання подання нижнього аркуша.

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)
}

І зателефонуйте в метод viewDidAppear:

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

Налаштування BottomSheetViewController

1) Підготуйте тло

Створіть метод для додавання ефектів розмитості та яскравості

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)
}

викликайте цей метод у вашому представленніWillAppear

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

Переконайтеся, що колір тла перегляду контролера чіткийColor.

2) Анімаційний вигляд знизу

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) Змініть свій xib так, як вам потрібно.

4) Додайте до свого перегляду розпізнавальник жестів панорами.

У вашому перегляді метод DidLoad додайте UIPanGestureRecognizer.

override func viewDidLoad() {
    super.viewDidLoad()

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

}

І реалізуйте свою поведінку жестами:

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)
}

Прокручуваний нижній лист:

Якщо ваш власний вигляд - це прокрутка або будь-який інший вид, який успадковується, то у вас є два варіанти:

Перший:

Створіть подання з видом заголовка та додайте до заголовка panGesture. (поганий досвід користувача) .

Друге:

1 - Додайте панель жестів до подання нижнього аркуша.

2 - Реалізуйте UIGestureRecognizerDelegate та встановіть делегат panGesture на контролер.

3- Реалізувати слідRecognizeSim istovremenoWith функцію делегування та відключити властивість scrollView isScrollEnabled у двох випадках:

  • Вид частково видно.
  • Вид повністю видно, Scrollview contentOffset властивість 0 і користувач перетягування виду вниз.

Інакше ввімкніть прокрутку.

  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
  }

ПРИМІТКА

Якщо ви встановите .allowUserInteraction як параметр анімації, як у зразковому проекті, тому вам потрібно включити прокрутку під час завершення роботи анімації, якщо користувач прокручує вгору.

Зразок проекту

Я створив зразок проекту з більшою кількістю варіантів цього репо, який може дати вам кращу інформацію про те, як налаштувати потік.

У демонстраційній системі функція addBottomSheetView () керує, подання якого слід використовувати як нижній аркуш.

Зразкові скріншоти проекту

- Частковий вигляд

введіть тут опис зображення

- FullView

введіть тут опис зображення

- Прокручуваний вид

введіть тут опис зображення


3
Це насправді хороший підручник для childViews та subView. Дякую :)
Едісон

@AhmedElassuty Як я можу додати tableview в xib і завантажувати як нижній аркуш?
nikhil nangia

3
@nikhilnangia Я щойно оновив репо-репортаж з іншим видом контролера "ScrollableBottomSheetViewController.swift", який містить tableView. Відповідь я оновлю якнайшвидше. Перевірте це також github.com/AhmedElassuty/IOS-BottomSheet/isissue/1
Ahmad Elassuty

12
Я помітив, що нижній аркуш Apple Maps обробляє перехід від жеста перетягування аркуша до жесту прокрутки подання прокрутки одним плавним рухом, тобто користувачеві не потрібно зупиняти один жест і починати новий, щоб почати прокручувати подання. Ваш приклад цього не робить. Чи є у вас думки щодо того, як це можна зробити? Дякую, і дякую тонну за розміщення прикладу в репо!
Stoph

2
@ RafaelaLourenço Я дуже радий, що ти знайшов мою відповідь корисною! Дякую!
Ахмад Еласуті

55

Я випустив бібліотеку, грунтуючись на моїй відповіді нижче.

Це імітує накладку програми ярликів. Детальну інформацію див. У цій статті .

Основним компонентом бібліотеки є OverlayContainerViewController. Він визначає область, де контролер перегляду можна перетягувати вгору та вниз, приховуючи або розкриваючи вміст під ним.

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

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

window?.rootViewController = containerController

Виконати, OverlayContainerViewControllerDelegateщоб вказати кількість бажаних насічок:

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
    }
}

Попередня відповідь

Я думаю, що є важливий момент, який не розглядається у запропонованих рішеннях: перехід між прокруткою та перекладом.

Перехід карт між прокруткою та перекладом

На Картах, як ви могли помітити, коли таблиця ViewView досягає contentOffset.y == 0, нижній аркуш або ковзає вгору, або знижується.

Справа хитра, тому що ми не можемо просто включити / відключити прокрутку, коли наш жест пан починає переклад. Це зупинить прокрутку, поки не почнеться новий штрих. Це стосується більшості запропонованих тут рішень.

Ось моя спроба здійснити цей рух.

Початкова точка: Додаток Maps

Для того, щоб почати наше дослідження, давайте візуалізувати ієрархію виду Карт (початок карти на тренажері і виберіть Debug> Attach to process by PID or Name> Mapsв Xcode 9).

Ієрархія перегляду налагодження на Картах

Це не говорить про те, як працює рух, але це допомогло мені зрозуміти його логіку. Ви можете грати з налагоджувачем lldb та ієрархією перегляду.

Наш контролер подає стеки

Створимо базову версію архітектури Maps ViewController.

Почнемо з BackgroundViewController(наш вид карти):

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

Ми ставимо таблицю ViewView виділеним UIViewController:

class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    lazy var tableView = UITableView()

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

    [...]
}

Тепер нам потрібен VC, щоб вбудувати накладку та керувати її перекладом. Для спрощення проблеми ми вважаємо, що вона може перекласти накладку з однієї статичної точки OverlayPosition.maximumв іншу OverlayPosition.minimum.

Наразі у нього є лише один публічний метод анімації зміни позиції, і він має прозорий вигляд:

enum OverlayPosition {
    case maximum, minimum
}

class OverlayContainerViewController: UIViewController {

    let overlayViewController: OverlayViewController
    var translatedViewHeightContraint = ...

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

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

Нарешті нам потрібен ViewController, щоб вставити всі:

class StackViewController: UIViewController {

    private var viewControllers: [UIViewController]

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

У нашому AppDelegate наша послідовність запуску виглядає так:

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

Складність перекладу накладання

Тепер, як перекласти нашу накладку?

Більшість запропонованих рішень використовують виділений розпізнавальник жестів панорами, але насправді вже є одне: жест панорами подання таблиці. Крім того, нам потрібно тримати прокрутку та переклад синхронізованими, а також UIScrollViewDelegateусі необхідні нам події!

Наївна реалізація використовує другий жест пан і намагається скинути contentOffsetперегляд таблиці, коли відбувається переклад:

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

Але це не працює. Таблиця TableView оновлює її, contentOffsetколи спрацьовує власна дія розпізнавача жестів, або коли викликається зворотний виклик displayLink. Немає шансів, що наш розпізнавальник спрацьовує відразу після тих, хто успішно переможе їх contentOffset. Наш єдиний шанс - або взяти участь у фазі компонування (шляхом переосмислення layoutSubviewsвикликів подання прокрутки в кожному кадрі подання прокрутки), або відповісти на didScrollметод делегата, що викликається щоразу, коли contentOffsetмодифікація змінюється. Спробуємо цю.

Реалізація перекладу

Ми додаємо делегата до нашого, OverlayVCщоб відправити події прокрутки до нашого обробника перекладів 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)
    }
}

У нашому контейнері ми відстежуємо переклад за допомогою enum:

enum OverlayInFlightPosition {
    case minimum
    case maximum
    case progressing
}

Поточний розрахунок позиції виглядає так:

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

Нам потрібні 3 методи для обробки перекладу:

Перший повідомляє нам, чи потрібно починати переклад.

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
    }
}

Другий виконує переклад. Він використовує translation(in:)метод жесту 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)
    )
}

Третя анімація закінчує переклад, коли користувач відпускає палець. Ми обчислюємо позицію, використовуючи швидкість & поточне положення подання.

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

Реалізація нашого делегата просто виглядає так:

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

Остаточна проблема: відправлення дотиків контейнера, що накладається

Переклад зараз досить ефективний. Але є ще остаточна проблема: штрихи не передаються нашому фоновому режиму. Усі вони перехоплені видом контейнера, що накладається. Ми не можемо встановити isUserInteractionEnabledце, falseоскільки це також відключить взаємодію в нашому перегляді таблиці. Рішення - це те, що масово використовується в додатку Карти 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
    }
}

Він видаляється з ланцюга реагування.

В OverlayContainerViewController:

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

Результат

Ось результат:

Результат

Ви можете знайти код тут .

Якщо ви бачите будь-які помилки, повідомте мене! Зауважте, що ваша реалізація може, звичайно, використовувати другий жест переміщення, особливо якщо ви додаєте заголовок у накладку.

Оновлення 23.08.18

Ми можемо замінити scrollViewDidEndDraggingз willEndScrollingWithVelocityчим enabling/ disablingсвиткою , коли кінці користувача перетягування:

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

Ми можемо використовувати весняну анімацію та дозволити взаємодію з користувачем під час анімації, щоб покращити рух руху:

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)
}

Цей підхід не є інтерактивним на етапах анімації. Наприклад, на Картах я можу вловити аркуш пальцем, коли він розширюється. Тоді я можу очистити анімацію, переміщуючи її вгору чи вниз. Він повернеться до того місця, де найближче. Я вважаю, що це можна вирішити, використовуючи UIViewPropertyAnimator(який має можливість робити паузу), а потім використати fractionCompleteвластивість для виконання скрабу.
авст

1
Ви маєте рацію @aust. Я забув підштовхнути свою попередню зміну. Зараз має бути добре. Дякую.
GaétanZ

Це чудова ідея, однак я можу відразу побачити одну проблему. У translateView(following:)методі ви обчислюєте висоту на основі перекладу, але це може починатися з іншого значення, ніж 0,0 (наприклад, подання таблиці прокручується трохи, а накладення максимізується, коли ви починаєте перетягувати) . Це призведе до початкового стрибка. Ви можете вирішити це за допомогою scrollViewWillBeginDraggingзворотного виклику там, де ви запам’ятали б початковий contentOffsetвигляд таблиці та використали його в translateView(following:)обчисленні.
Якуб Трулахр

1
@ GaétanZ Мені було цікаво приєднати налагоджувач до Maps.app на Simulator, але я отримав помилку "Не вдалося приєднати до pid:" 9276 "", а потім "Переконайтесь, що" Карти "ще не працює, і <username> має дозвіл на налагодити це. " - Як ви обдурили Xcode, щоб дозволити приєднання до Maps.app?
математика

1
@matm @ GaétanZ працював на мені над Xcode 10.1 / Mojave -> просто потрібно відключити, щоб відключити захист цілісності системи (SIP). Вам потрібно: 1) перезавантажити комп'ютер; 2) Утримуйте cmd + R; 3) У меню виберіть Утиліти, потім Термінал; 4) У вікні терміналу типу: csrutil disable && reboot. Тоді ви матимете всі повноваження, котрі зазвичай має надавати root, включаючи можливість приєднання до процесу Apple.
valdyr

18

Спробуйте Шків :

Шкір - це проста у користуванні бібліотека висувних ящиків, призначена для імітації ящика в додатку Карти iOS 10. Він відкриває простий API, який дозволяє використовувати будь-який підклас UIViewController як вміст ящика або основний вміст.

Pulley Попередній перегляд

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


1
Чи підтримує він прокручування в нижньому списку?
Річард Ліндхут

@RichardLindhout - Після запуску демонстрації схоже, що він підтримує прокрутку, але не плавний перехід від переміщення ящика до прокрутки списку.
Stoph

Приклад, схоже, приховує юридичне посилання Apple в нижньому лівому куті.
Герд Кастан

1
Супер просте запитання, як ви отримали індикатор у верхній частині нижнього аркуша?
Ізраїль

12

Ви можете спробувати мою відповідь https://github.com/SCENEE/FloatingPanel . Він забезпечує контролер подання контейнера для відображення інтерфейсу "нижнього аркуша".

Це проста у використанні, і ви не заперечуєте проти використання розпізнавальника жестів! Також ви можете відстежувати подання прокрутки (або подання побратимів) на нижньому аркуші, якщо потрібно.

Це простий приклад. Зверніть увагу, що вам потрібно підготувати контролер перегляду для відображення вмісту в нижньому аркуші.

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)    
    }
    ...
}

Ось ще один приклад коду для відображення нижнього аркуша для пошуку місця, наприклад Apple Maps.

Зразок програми, як Apple Maps


fpc.set (contentViewController: newsVC), метод відсутній у бібліотеці
Фаріс Мухаммед

1
Супер просте запитання, як ви отримали індикатор у верхній частині нижнього аркуша?
Ізраїль

1
@AIsrafil Я думаю, що це просто UIView
ShadeToD

Я думаю, що це дійсно приємне рішення, але в iOS 13 є проблема з представленням контролера модального перегляду з цією плаваючою панеллю.
ShadeToD

11

Я написав власну бібліотеку, щоб досягти наміченої поведінки в додатку ios Maps. Це рішення, орієнтоване на протокол. Тож вам не потрібно успадковувати жоден базовий клас, а замість цього створити контролер аркушів та налаштувати за своїм бажанням. Він також підтримує внутрішню навігацію / презентацію з або без UINavigationController або без нього.

Дивіться посилання нижче для отримання більш детальної інформації.

https://github.com/OfTheWolf/UBottomSheet

ubottom аркуш


ЦЕ повинна бути обраною відповіддю, оскільки ви все ще можете натиснути на VC позаду спливаючого вікна.
Jay

Супер просте запитання, як ви отримали індикатор у верхній частині нижнього аркуша?
Ізраїль

1

Нещодавно я створив компонент, який називається SwipeableViewпідкласом UIView, написаний у Swift 5.1. Він підтримує всі 4 напрямки, має кілька варіантів налаштування та може анімувати та інтерполювати різні атрибути та елементи (такі як обмеження макета, колір тла / відтінку, чітке перетворення, альфа-канал та центр перегляду; усі вони демонструються відповідною виставкою). Він також підтримує координацію перемикання з внутрішнім видом прокрутки, якщо встановлено або автоматично виявлено. Має бути досить простим та простим у використанні (сподіваюся, 🙂)

Посилання на https://github.com/LucaIaco/SwipeableView

доказ концепції:

введіть тут опис зображення

Сподіваюся, це допомагає


0

Можливо, ви можете спробувати мою відповідь https://github.com/AnYuan/AYPannel , натхненний Pulley. Плавний перехід від переміщення ящика до прокрутки списку. Я додав жест панорамування у вікно прокрутки контейнера і встановив повиненRecognizeSim istovremenoWithGestureRecognizer повернути YES. Більш докладно у моєму посиланні на github. Бажання допомогти.


6
Хоча це посилання може відповісти на питання, краще включити сюди суттєві частини відповіді та надати посилання для довідки. Відповіді лише на посилання можуть стати недійсними, якщо пов’язана сторінка зміниться. Хоча це посилання може відповісти на питання, краще включити сюди суттєві частини відповіді та надати посилання для довідки. Відповіді лише на посилання можуть стати недійсними, якщо пов’язана сторінка зміниться.
пірхо
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.