¿Cómo puedo imitar la hoja inferior de la aplicación Mapas?


202

¿Alguien puede decirme cómo puedo imitar la hoja inferior en la nueva aplicación Maps en iOS 10?

En Android, puede usar uno BottomSheetque imite este comportamiento, pero no pude encontrar algo así para iOS.

¿Es una simple vista de desplazamiento con un contenido insertado, de modo que la barra de búsqueda está en la parte inferior?

Soy bastante nuevo en la programación de iOS, por lo que si alguien pudiera ayudarme a crear este diseño, sería muy apreciado.

Esto es lo que quiero decir con "hoja inferior":

captura de pantalla de la hoja inferior contraída en Maps

captura de pantalla de la hoja inferior expandida en Maps


1
Tienes que construirlo tú mismo, me temo. Es una vista con fondo borroso y algunos reconocedores de gestos.
Khanh Nguyen

@KhanhNguyen sí Yo sé que tengo que construir yo mismo, pero lo que me pregunto es qué vistas se utilizan para archieve este efecto y la forma en que se ordenan y así sucesivamente
qwertz

Es una vista de efecto visual con efecto de desenfoque. Ver esta pregunta SO
Khanh Nguyen

1
Creo que puede agregar un reconocedor de gestos panorámicos a la vista inferior y mover la vista en consecuencia (al observar los cambios almacenando las coordenadas y entre los eventos del reconocedor de gestos y calcular la diferencia).
Khanh Nguyen

2
Me gustan tus preguntas Estoy planeando implementar algo como esto. Si alguien pudiera escribir algún ejemplo sería bueno.
wviana

Respuestas:


330

No sé cómo responde exactamente la hoja inferior de la nueva aplicación Maps a las interacciones de los usuarios. Pero puede crear una vista personalizada que se parezca a la de las capturas de pantalla y agregarla a la vista principal.

Supongo que sabes cómo:

1- crea controladores de vista ya sea por guiones gráficos o usando archivos xib.

2- usa googleMaps o el MapKit de Apple.

Ejemplo

1- Cree 2 controladores de vista, por ejemplo, MapViewController y BottomSheetViewController . El primer controlador alojará el mapa y el segundo es la hoja inferior.

Configurar MapViewController

Cree un método para agregar la vista de la hoja 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)
}

Y llámelo en el método viewDidAppear:

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

Configurar BottomSheetViewController

1) Preparar el fondo

Cree un método para agregar efectos de desenfoque y vitalidad

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

llame a este método en su vista

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

Asegúrese de que el color de fondo de la vista de su controlador sea clearColor.

2) Animar la apariencia de la hoja inferior

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) Modifica tu xib como quieras.

4) Agregue el Reconocimiento de gestos panorámicos a su vista.

En su método viewDidLoad, agregue UIPanGestureRecognizer.

override func viewDidLoad() {
    super.viewDidLoad()

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

}

E implemente su comportamiento 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)
}

Hoja inferior desplazable:

Si su vista personalizada es una vista de desplazamiento o cualquier otra vista que herede, entonces tiene dos opciones:

Primero:

Diseñe la vista con una vista de encabezado y agregue panGesture al encabezado. (mala experiencia del usuario) .

Segundo:

1 - Agregue panGesture a la vista de la hoja inferior.

2 - Implemente el UIGestureRecognizerDelegate y configure el delegado panGesture en el controlador.

3- Implemente shouldRecognizeSim simultáneamente con la función delegar y deshabilite la propiedad scrollView isScrollEnabled en dos casos:

  • La vista es parcialmente visible.
  • La vista es totalmente visible, la propiedad scrollView contentOffset es 0 y el usuario arrastra la vista hacia abajo.

De lo contrario, habilite el desplazamiento.

  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

En caso de que establezca .allowUserInteraction como una opción de animación, como en el proyecto de muestra, debe habilitar el desplazamiento en el cierre de finalización de la animación si el usuario se desplaza hacia arriba.

Proyecto de muestra

Creé un proyecto de muestra con más opciones en este repositorio que puede brindarle una mejor perspectiva sobre cómo personalizar el flujo.

En la demostración, la función addBottomSheetView () controla qué vista debe usarse como una hoja inferior.

Ejemplos de capturas de pantalla del proyecto

- Vista parcial

ingrese la descripción de la imagen aquí

- Vista completa

ingrese la descripción de la imagen aquí

- Vista desplazable

ingrese la descripción de la imagen aquí


3
Este es realmente un buen tutorial para childViews y subViews. Gracias :)
Edison

@AhmedElassuty, ¿cómo puedo agregar tableview en xib y cargar como hoja inferior? Si pudiera ayudar, sería genial ¡Gracias de antemano!
nikhil nangia

3
@nikhilnangia Acabo de actualizar el repositorio con otro viewController "ScrollableBottomSheetViewController.swift" que contiene un tableView. Actualizaré la respuesta lo antes posible. Compruebe esto también github.com/AhmedElassuty/IOS-BottomSheet/issues/1
Ahmad Elassuty

12
Noté que la hoja inferior de Apple Maps maneja la transición del gesto de arrastre de la hoja al gesto de desplazamiento de la vista de desplazamiento en un movimiento suave, es decir, el usuario no necesita detener un gesto e iniciar uno nuevo para comenzar a desplazar la vista. Tu ejemplo no hace esto. ¿Tienes alguna idea sobre cómo se podría lograr eso? ¡Gracias y muchas gracias por publicar el ejemplo en un repositorio!
Stoph

2
@ RafaelaLourenço ¡Estoy muy feliz de que mi respuesta haya sido útil! ¡Gracias!
Ahmad Elassuty

56

Lancé una biblioteca basada en mi respuesta a continuación.

Imita la superposición de la aplicación Atajos. Vea este artículo para más detalles.

El componente principal de la biblioteca es el OverlayContainerViewController. Define un área donde se puede arrastrar un controlador de vista hacia arriba y hacia abajo, ocultando o revelando el contenido debajo de él.

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

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

window?.rootViewController = containerController

Implemente OverlayContainerViewControllerDelegatepara especificar el número de muescas deseadas:

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

Respuesta anterior

Creo que hay un punto significativo que no se trata en las soluciones sugeridas: la transición entre el desplazamiento y la traducción.

Mapas de transición entre el pergamino y la traducción

En Maps, como habrás notado, cuando alcanza TableView contentOffset.y == 0, la hoja inferior se desliza hacia arriba o hacia abajo.

El punto es complicado porque no podemos simplemente habilitar / deshabilitar el desplazamiento cuando nuestro gesto panorámico comienza la traducción. Pararía el desplazamiento hasta que comience un nuevo toque. Este es el caso en la mayoría de las soluciones propuestas aquí.

Aquí está mi intento de implementar esta moción.

Punto de partida: aplicación de mapas

Para iniciar nuestra investigación, vamos a visualizar la vista de jerarquía de Mapas (iniciar Mapas en un simulador y seleccione Debug> Attach to process by PID or Name> Mapsen Xcode 9).

Jerarquía de vista de depuración de mapas

No dice cómo funciona el movimiento, pero me ayudó a entender la lógica del mismo. Puedes jugar con el lldb y el depurador de jerarquía de vistas.

Nuestras pilas de controladores de vista

Creemos una versión básica de la arquitectura Maps ViewController.

Comenzamos con un BackgroundViewController(nuestra vista de mapa):

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

Ponemos el tableView en un dedicado UIViewController:

class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    lazy var tableView = UITableView()

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

    [...]
}

Ahora, necesitamos un VC para incrustar la superposición y administrar su traducción. Para simplificar el problema, consideramos que puede traducir la superposición de un punto estático OverlayPosition.maximuma otro OverlayPosition.minimum.

Por ahora solo tiene un método público para animar el cambio de posición y tiene una vista 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 necesitamos un ViewController para incrustar todo:

class StackViewController: UIViewController {

    private var viewControllers: [UIViewController]

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

En nuestro AppDelegate, nuestra secuencia de inicio se ve así:

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

La dificultad detrás de la traducción superpuesta

Ahora, ¿cómo traducir nuestra superposición?

La mayoría de las soluciones propuestas utilizan un reconocedor de gestos panorámicos dedicado, pero en realidad ya tenemos uno: el gesto panorámico de la vista de tabla. Además, necesitamos mantener sincronizados el desplazamiento y la traducción, ¡y UIScrollViewDelegatetiene todos los eventos que necesitamos!

Una implementación ingenua usaría un segundo gesto panorámico e intentaría restablecer la contentOffsetvista de la tabla cuando se produzca la traducción:

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

Pero no funciona. TableView actualiza contentOffsetcuando se activa su propia acción de reconocimiento de gestos panorámicos o cuando se llama a su devolución de llamada displayLink. No hay posibilidad de que nuestro reconocedor se active inmediatamente después de aquellos para anular con éxito el contentOffset. Nuestra única posibilidad es participar en la fase de diseño (anulando layoutSubviewslas llamadas de la vista de desplazamiento en cada cuadro de la vista de desplazamiento) o responder al didScrollmétodo del delegado llamado cada vez que contentOffsetse modifica. Probemos con este.

La implementación de la traducción

OverlayVCAgregamos un delegado a nuestro para enviar los eventos de la vista de desplazamiento a nuestro controlador de traducción, el 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)
    }
}

En nuestro contenedor, hacemos un seguimiento de la traducción usando una enumeración:

enum OverlayInFlightPosition {
    case minimum
    case maximum
    case progressing
}

El cálculo de la posición actual se ve así:

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

Necesitamos 3 métodos para manejar la traducción:

El primero nos dice si necesitamos comenzar la traducción.

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

El segundo realiza la traducción. Utiliza el translation(in:)método del gesto panorámico de 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)
    )
}

El tercero anima el final de la traducción cuando el usuario suelta su dedo. Calculamos la posición usando la velocidad y la posición actual de la vista.

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

La implementación de delegado de nuestra superposición simplemente se ve así:

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 los toques del contenedor de superposición

La traducción ahora es bastante eficiente. Pero todavía hay un problema final: los toques no se entregan a nuestra vista de fondo. Todos son interceptados por la vista del contenedor de superposición. No podemos establecer isUserInteractionEnabledque false, ya que también podría desactivar la interacción en nuestra opinión mesa. La solución es la que se usa masivamente en la aplicación Mapas 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
    }
}

Se elimina de la cadena de respuesta.

En OverlayContainerViewController:

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

Resultado

Aquí está el resultado:

Resultado

Puedes encontrar el código aquí .

Por favor, si ve algún error, ¡avíseme! Tenga en cuenta que su implementación puede, por supuesto, usar un segundo gesto de desplazamiento, especialmente si agrega un encabezado en su superposición.

Actualización 23/08/18

Podemos reemplazar scrollViewDidEndDraggingcon en willEndScrollingWithVelocitylugar de enabling/ disablingel desplazamiento cuando el usuario termina de arrastrar:

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 una animación de primavera y permitir la interacción del usuario mientras animamos para que el movimiento fluya mejor:

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

Este enfoque no es interactivo durante las etapas de animación. Por ejemplo, en Maps, puedo atrapar la hoja con el dedo a medida que se expande. Luego puedo restregar la animación desplazándome hacia arriba o hacia abajo. Regresará al lugar más cercano. Creo que esto se puede resolver usando UIViewPropertyAnimator(que tiene la capacidad de pausar), luego use la fractionCompletepropiedad para realizar la limpieza.
agosto

1
Tienes razón @aust. Olvidé presionar mi cambio anterior. Debería estar bien ahora. Gracias.
GaétanZ

Esta es una gran idea, sin embargo, puedo ver un problema de inmediato. En el translateView(following:)método, calcula la altura en función de la traducción, pero eso podría comenzar desde un valor diferente a 0.0 (por ejemplo, la vista de tabla se desplaza un poco y la superposición se maximiza cuando comienza a arrastrar) . Eso resultaría en el salto inicial. Puede resolver esto mediante la scrollViewWillBeginDraggingdevolución de llamada donde recordaría la inicial contentOffsetde la vista de tabla y la usaría en el translateView(following:)cálculo de.
Jakub Truhlář

1
@ GaétanZ También estaba interesado en adjuntar el depurador a Maps.app en el simulador, pero aparece un error "No se pudo adjuntar a pid:" 9276 "" seguido de "Asegúrese de que" Maps "no se esté ejecutando y <username> tiene permiso para depurarlo ". - ¿Cómo engañaste a Xcode para permitir adjuntarlo a Maps.app?
matm

1
@matm @ GaétanZ funcionó para mí en Xcode 10.1 / Mojave -> solo necesito deshabilitar para deshabilitar la Protección de integridad del sistema (SIP). Necesita: 1) Reiniciar su mac; 2) Mantenga presionado cmd + R; 3) En el menú, seleccione Utilidades y luego Terminal; 4) En el tipo de ventana Terminal: csrutil disable && reboot. Entonces tendrá todos los poderes que la raíz debería darle normalmente, incluida la posibilidad de conectarse a un proceso de Apple.
valdyr

18

Prueba la polea :

Pulley es una biblioteca de cajones fácil de usar destinada a imitar el cajón en la aplicación Maps de iOS 10. Expone una API simple que le permite usar cualquier subclase de UIViewController como contenido del cajón o contenido principal.

Vista previa de polea

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


1
¿Es compatible con el desplazamiento en la lista subyacente?
Richard Lindhout

@RichardLindhout: después de ejecutar la demostración, parece que admite el desplazamiento, pero no la transición suave de mover el cajón a desplazar la lista.
Stoph

El ejemplo parece ocultar el enlace legal de Apple en la parte inferior izquierda.
Gerd Castan

1
Pregunta súper simple, ¿cómo obtuvo el indicador en la parte superior de la hoja inferior?
Un Israfil

12

Puedes probar mi respuesta https://github.com/SCENEE/FloatingPanel . Proporciona un controlador de vista de contenedor para mostrar una interfaz de "hoja inferior".

¡Es fácil de usar y no le importa el manejo del reconocedor de gestos! También puede rastrear una vista de desplazamiento (o la vista de hermanos) en una hoja inferior si es necesario.

Este es un ejemplo simple. Tenga en cuenta que debe preparar un controlador de vista para mostrar su contenido en una hoja 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)    
    }
    ...
}

Aquí hay otro código de ejemplo para mostrar una hoja inferior para buscar una ubicación como Apple Maps.

Aplicación de muestra como Apple Maps


fpc.set (contentViewController: newsVC), método que falta en la biblioteca
Faris Muhammed

1
Pregunta súper simple, ¿cómo obtuvo el indicador en la parte superior de la hoja inferior?
Un Israfil

1
@AIsrafil Creo que es solo una vista de UIView
ShadeToD

Creo que es una solución realmente agradable, pero hay un problema en iOS 13 con la presentación del controlador de vista modal con este panel flotante.
ShadeToD

12

Escribí mi propia biblioteca para lograr el comportamiento deseado en la aplicación ios Maps. Es una solución orientada al protocolo. Por lo tanto, no necesita heredar ninguna clase base, sino crear un controlador de hoja y configurarlo como desee. También es compatible con la navegación / presentación interna con o sin UINavigationController.

Vea el siguiente enlace para más detalles.

https://github.com/OfTheWolf/UBottomSheet

hoja inferior


ESTA debería ser la respuesta seleccionada, ya que aún puede hacer clic en el VC detrás de la ventana emergente.
Jay

Pregunta súper simple, ¿cómo obtuvo el indicador en la parte superior de la hoja inferior?
Un Israfil

1

Recientemente creé un componente llamado SwipeableViewcomo subclase de UIView, escrito en Swift 5.1. Admite las 4 direcciones, tiene varias opciones de personalización y puede animar e interpolar diferentes atributos y elementos (como restricciones de diseño, color de fondo / tinte, transformación afín, canal alfa y centro de visualización, todos demostrados con el caso de exhibición respectivo). También admite la coordinación de deslizamiento con la vista de desplazamiento interno si está configurado o se detecta automáticamente. Debería ser bastante fácil y sencillo de usar (espero 🙂)

Enlace en https://github.com/LucaIaco/SwipeableView

prueba de concepto:

ingrese la descripción de la imagen aquí

Espero eso ayude


0

Quizás puedas probar mi respuesta https://github.com/AnYuan/AYPannel , inspirada en Pulley. Transición suave de mover el cajón a desplazarse por la lista. Agregué un gesto de desplazamiento en la vista de desplazamiento del contenedor y configuré shouldRecognizeSim simultáneamenteWithGestureRecognizer para que devuelva SÍ. Más detalles en mi enlace de github arriba. Deseo ayudar


66
Si bien este enlace puede responder la pregunta, es mejor incluir las partes esenciales de la respuesta aquí y proporcionar el enlace como referencia. Las respuestas de solo enlace pueden volverse inválidas si la página vinculada cambia. Si bien este enlace puede responder la pregunta, es mejor incluir las partes esenciales de la respuesta aquí y proporcionar el enlace como referencia. Las respuestas de solo enlace pueden volverse inválidas si la página vinculada cambia.
pirho
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.